feat: harden company resume, external sessions, and Office UI i18n

Improve suspend/resume identity continuity and external broker session handling, add Office UI internationalization with rebuilt assets, and expand company org configs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
小东
2026-07-16 09:20:40 +08:00
parent eb934cf2bd
commit cb6b8ed6e1
32 changed files with 2562 additions and 446 deletions
@@ -0,0 +1,413 @@
schema_version: 2
kind: opc_org_architecture
organization_id: cross-border-ecommerce
organization_name: Cross-Border Ecommerce Team
company:
name: Cross-Border Ecommerce Team
topology: Ecommerce Growth Hub
company_profile: custom
execution_model: actor_runtime
final_decider_role_id: ecommerce_director
company_profiles:
- corporate
- custom
roles:
- id: ecommerce_director
name: Ecommerce Director
responsibility: Owns market choice, commercial goals, portfolio priorities, budget
allocation, operating cadence, risk decisions, and final delivery.
reports_to: owner
icon: leader
can_spawn:
- market_researcher
- listing_localization_specialist
- paid_media_specialist
- social_content_specialist
- operations_manager
- finance_analyst
tools: []
preferred_external_agent: codex
prompt_refs:
- Clarify target platform, country, category, customer, margin target, budget, inventory
constraints, and time horizon before delegating.
- Require every recommendation to separate verified facts, assumptions, experiments,
owners, metrics, and stop conditions.
skill_refs:
- ecommerce-operating-system
- market-product-research
- compliance-freshness
- cross-border-guide
handoff_template_ref: null
memory_policy_ref: null
artifact_contract_ref: null
runtime_policy:
execution_strategy: native
allowed_downstream_roles:
- market_researcher
- listing_localization_specialist
- paid_media_specialist
- social_content_specialist
- operations_manager
- finance_analyst
review_role: null
default_turn_type: work
shell_timeout_override: null
setup_env_type: null
coordination_hints: {}
signal_capabilities: []
parallelism_constraints: []
gate_preferences: {}
capabilities: []
role_type: coordinator
coordinator_policy: null
- id: market_researcher
name: Market and Product Researcher
responsibility: Researches demand, competition, pricing, reviews, trends, product
opportunities, and market-entry evidence.
reports_to: ecommerce_director
icon: search
can_spawn: []
tools: []
preferred_external_agent: codex
prompt_refs:
- Use current sources, record URLs and access dates, compare multiple competitors,
and quantify confidence and data gaps.
skill_refs:
- market-product-research
- compliance-freshness
handoff_template_ref: null
memory_policy_ref: null
artifact_contract_ref: null
runtime_policy:
execution_strategy: auto
allowed_downstream_roles: []
review_role: null
default_turn_type: research
shell_timeout_override: null
setup_env_type: null
coordination_hints: {}
signal_capabilities: []
parallelism_constraints: []
gate_preferences: {}
capabilities: []
role_type: worker
coordinator_policy: null
- id: listing_localization_specialist
name: Listing and Localization Specialist
responsibility: Produces localized product positioning, titles, bullets, descriptions,
keyword maps, image briefs, and storefront copy.
reports_to: ecommerce_director
icon: writing
can_spawn: []
tools: []
preferred_external_agent: codex
prompt_refs:
- Adapt claims and terminology to the target market and platform. Never invent certifications,
performance claims, or product facts.
skill_refs:
- listing-localization
- market-product-research
- compliance-freshness
handoff_template_ref: null
memory_policy_ref: null
artifact_contract_ref: null
runtime_policy:
execution_strategy: auto
allowed_downstream_roles: []
review_role: null
default_turn_type: work
shell_timeout_override: null
setup_env_type: null
coordination_hints: {}
signal_capabilities: []
parallelism_constraints: []
gate_preferences: {}
capabilities: []
role_type: worker
coordinator_policy: null
- id: paid_media_specialist
name: Paid Media Specialist
responsibility: Designs and optimizes PPC and paid-social campaigns, budgets, tracking,
experiments, and profitability controls.
reports_to: ecommerce_director
icon: target
can_spawn: []
tools: []
preferred_external_agent: codex
prompt_refs:
- Define funnel stage, audience, creative hypothesis, budget, attribution limits,
success metric, guardrails, and stop-loss rule for every campaign.
skill_refs:
- paid-media-optimization
- ecommerce-operating-system
handoff_template_ref: null
memory_policy_ref: null
artifact_contract_ref: null
runtime_policy:
execution_strategy: auto
allowed_downstream_roles: []
review_role: null
default_turn_type: work
shell_timeout_override: null
setup_env_type: null
coordination_hints: {}
signal_capabilities: []
parallelism_constraints: []
gate_preferences: {}
capabilities: []
role_type: worker
coordinator_policy: null
- id: social_content_specialist
name: Social Content Specialist
responsibility: Plans platform-native organic content, creator briefs, short-video
concepts, publishing cadence, and community feedback loops.
reports_to: ecommerce_director
icon: marketing
can_spawn: []
tools: []
preferred_external_agent: codex
prompt_refs:
- Build content around verified product value, target-customer situations, platform-native
formats, and measurable calls to action.
skill_refs:
- listing-localization
- paid-media-optimization
handoff_template_ref: null
memory_policy_ref: null
artifact_contract_ref: null
runtime_policy:
execution_strategy: auto
allowed_downstream_roles: []
review_role: null
default_turn_type: work
shell_timeout_override: null
setup_env_type: null
coordination_hints: {}
signal_capabilities: []
parallelism_constraints: []
gate_preferences: {}
capabilities: []
role_type: worker
coordinator_policy: null
- id: operations_manager
name: Ecommerce Operations Manager
responsibility: Owns catalog operations, order flow, inventory, fulfillment, customer
service coordination, SOPs, and operational incident handling.
reports_to: ecommerce_director
icon: settings
can_spawn:
- supply_chain_specialist
- customer_service_specialist
tools: []
preferred_external_agent: codex
prompt_refs:
- Convert recommendations into repeatable SOPs with owner, trigger, input, output,
SLA, exception path, and audit evidence.
skill_refs:
- ecommerce-operating-system
- inventory-fulfillment
- cross-border-guide
handoff_template_ref: null
memory_policy_ref: null
artifact_contract_ref: null
runtime_policy:
execution_strategy: native
allowed_downstream_roles:
- supply_chain_specialist
- customer_service_specialist
review_role: null
default_turn_type: work
shell_timeout_override: null
setup_env_type: null
coordination_hints: {}
signal_capabilities: []
parallelism_constraints: []
gate_preferences: {}
capabilities: []
role_type: coordinator
coordinator_policy: null
- id: supply_chain_specialist
name: Supply Chain Specialist
responsibility: Plans sourcing, lead times, replenishment, safety stock, logistics
options, landed cost, and inventory risk.
reports_to: operations_manager
icon: database
can_spawn: []
tools: []
preferred_external_agent: codex
prompt_refs:
- State assumptions for demand, lead time, MOQ, defect rate, freight, duties, and
service level; provide sensitivity ranges instead of false precision.
skill_refs:
- inventory-fulfillment
- ecommerce-operating-system
- cross-border-guide
handoff_template_ref: null
memory_policy_ref: null
artifact_contract_ref: null
runtime_policy:
execution_strategy: auto
allowed_downstream_roles: []
review_role: null
default_turn_type: work
shell_timeout_override: null
setup_env_type: null
coordination_hints: {}
signal_capabilities: []
parallelism_constraints: []
gate_preferences: {}
capabilities: []
role_type: worker
coordinator_policy: null
- id: customer_service_specialist
name: Customer Service Specialist
responsibility: Designs support macros, escalation paths, return handling, review
response, complaint analysis, and voice-of-customer feedback.
reports_to: operations_manager
icon: message
can_spawn: []
tools: []
preferred_external_agent: codex
prompt_refs:
- Keep responses factual, empathetic, policy-compliant, and explicit about what
requires human approval or account access.
skill_refs:
- customer-service-voice
- compliance-freshness
handoff_template_ref: null
memory_policy_ref: null
artifact_contract_ref: null
runtime_policy:
execution_strategy: auto
allowed_downstream_roles: []
review_role: null
default_turn_type: work
shell_timeout_override: null
setup_env_type: null
coordination_hints: {}
signal_capabilities: []
parallelism_constraints: []
gate_preferences: {}
capabilities: []
role_type: worker
coordinator_policy: null
- id: finance_analyst
name: Ecommerce Finance Analyst
responsibility: Models unit economics, pricing, contribution margin, CAC, ROAS,
cash conversion, scenario risk, and KPI reporting.
reports_to: ecommerce_director
icon: chart
can_spawn: []
tools: []
preferred_external_agent: codex
prompt_refs:
- Show formulas, currencies, tax and fee assumptions, data sources, and sensitivity
ranges. Flag missing inputs rather than guessing silently.
skill_refs:
- ecommerce-unit-economics
- paid-media-optimization
handoff_template_ref: null
memory_policy_ref: null
artifact_contract_ref: null
runtime_policy:
execution_strategy: auto
allowed_downstream_roles: []
review_role: null
default_turn_type: analysis
shell_timeout_override: null
setup_env_type: null
coordination_hints: {}
signal_capabilities: []
parallelism_constraints: []
gate_preferences: {}
capabilities: []
role_type: worker
coordinator_policy: null
employees: []
escalation_rules: []
runtime_policies:
custom:
communication:
default_mode: dm
blocking_default: true
meeting_required_for:
- budget_change
- compliance_risk
- inventory_risk
allow_broadcast: true
memory:
include_role_memory: true
include_project_memory: true
include_decision_log: true
include_artifact_index: true
recent_history_lines: 16
handoff:
require_structured_handoff: true
require_ack: true
include_risks: true
include_open_questions: true
artifact:
enforce_contract: false
require_artifact_index: true
required_kinds: []
review:
enable_work_item_gates: true
strict_gate_inference: true
require_reviewer_role: true
allow_human_override: true
gate_harness:
enabled: true
decision_mode: agent_first
default_degrade_policy: allow
auto_infer_turn_kind: true
auto_infer_gate_profile: true
max_rework_rounds_per_issue: 2
stagnation_threshold: 2
allow_pass_with_constraints: true
enable_delivery_constraints_propagation: true
agent_model: ''
agent_confidence_threshold: 0.55
fallback_to_rules_on_parse_error: true
builtin_blocker_map:
workspace_mismatch: rework_same_work_item
missing_required_artifact: rework_same_work_item
verification_missing: rework_same_work_item
dependency_not_ready: rework_same_work_item
cross_work_item_conflict: replan
external_secret_missing: await_user_decision
permission_missing: escalate
environment_capability_gap: pass_with_constraints
quality_gap: rework_same_work_item
goal_env_mismatch: replan
unresolved_user_visible_risk: pass_with_constraints
turn_kind_overrides: {}
gate_profile_overrides: {}
parallel:
auto_dispatch: true
review_gate_enabled: true
max_workers: 7
coordination:
inference_mode: llm_primary
fallback_mode: conservative
strict_gate_turn_kinds:
- verify
- deliver
mixed_gate_turn_kinds:
- synthesize
- review
- integration
allow_manager_release_for_mixed_only: true
allow_custom_signals: true
talent_templates: []
teams: []
team_runtime:
default_team_id: ''
shared_role_session_scope: team
allow_shared_role_sessions: true
seat_refresh_interval_seconds: 30
metadata: {}
installed_packages: []
role_serial_queue_enabled: true
metadata:
source: org_mode
organization_config_file: company_orgs/org_cross-border-ecommerce_config.yaml
@@ -0,0 +1,380 @@
schema_version: 2
kind: opc_org_architecture
organization_id: software-development
organization_name: Software Development Team
company:
name: Software Development Team
topology: Product Engineering Hub
company_profile: custom
execution_model: actor_runtime
final_decider_role_id: engineering_manager
company_profiles:
- corporate
- custom
roles:
- id: engineering_manager
name: Engineering Manager
responsibility: Owns scope, delivery, staffing, cross-role coordination, risk decisions,
and final acceptance for software projects.
reports_to: owner
icon: leader
can_spawn:
- product_manager
- software_architect
- qa_engineer
- security_reviewer
tools: []
preferred_external_agent: codex
prompt_refs:
- Clarify the user goal, repository boundaries, constraints, acceptance criteria,
and delivery order before delegating.
- Require implementation evidence, focused tests, review findings, deployment notes,
and explicit residual risks before final delivery.
skill_refs:
- repo-onboarding
- software-delivery
- release-readiness
handoff_template_ref: null
memory_policy_ref: null
artifact_contract_ref: null
runtime_policy:
execution_strategy: native
allowed_downstream_roles:
- product_manager
- software_architect
- qa_engineer
- security_reviewer
review_role: null
default_turn_type: work
shell_timeout_override: null
setup_env_type: null
coordination_hints: {}
signal_capabilities: []
parallelism_constraints: []
gate_preferences: {}
capabilities: []
role_type: coordinator
coordinator_policy: null
- id: product_manager
name: Product Manager
responsibility: Converts business goals into prioritized requirements, user flows,
acceptance criteria, and release scope.
reports_to: engineering_manager
icon: clipboard
can_spawn: []
tools: []
preferred_external_agent: codex
prompt_refs:
- Produce testable requirements and identify assumptions, exclusions, dependencies,
and user-visible behavior.
skill_refs:
- software-delivery
handoff_template_ref: null
memory_policy_ref: null
artifact_contract_ref: null
runtime_policy:
execution_strategy: auto
allowed_downstream_roles: []
review_role: null
default_turn_type: work
shell_timeout_override: null
setup_env_type: null
coordination_hints: {}
signal_capabilities: []
parallelism_constraints: []
gate_preferences: {}
capabilities: []
role_type: worker
coordinator_policy: null
- id: software_architect
name: Software Architect
responsibility: Owns repository analysis, architecture, technical decomposition,
interfaces, data design, and engineering integration.
reports_to: engineering_manager
icon: code
can_spawn:
- frontend_engineer
- backend_engineer
- devops_engineer
tools: []
preferred_external_agent: codex
prompt_refs:
- Inspect the existing repository before proposing architecture. Prefer existing
patterns and define contracts between work items.
- Review implementation integration and reject changes that violate repository boundaries
or lack verification.
skill_refs:
- repo-onboarding
- software-delivery
- code-review-gate
- code-review
handoff_template_ref: null
memory_policy_ref: null
artifact_contract_ref: null
runtime_policy:
execution_strategy: native
allowed_downstream_roles:
- frontend_engineer
- backend_engineer
- devops_engineer
review_role: null
default_turn_type: work
shell_timeout_override: null
setup_env_type: null
coordination_hints: {}
signal_capabilities: []
parallelism_constraints: []
gate_preferences: {}
capabilities: []
role_type: coordinator
coordinator_policy: null
- id: frontend_engineer
name: Frontend Engineer
responsibility: Implements accessible, responsive, tested frontend behavior using
the existing design system and framework conventions.
reports_to: software_architect
icon: design
can_spawn: []
tools: []
preferred_external_agent: codex
prompt_refs:
- Implement complete UI states and verify desktop and mobile behavior with focused
tests or browser evidence.
skill_refs:
- software-delivery
- testing-quality-gate
handoff_template_ref: null
memory_policy_ref: null
artifact_contract_ref: null
runtime_policy:
execution_strategy: external
allowed_downstream_roles: []
review_role: null
default_turn_type: work
shell_timeout_override: null
setup_env_type: null
coordination_hints: {}
signal_capabilities: []
parallelism_constraints: []
gate_preferences: {}
capabilities: []
role_type: worker
coordinator_policy: null
- id: backend_engineer
name: Backend Engineer
responsibility: Implements APIs, domain logic, persistence, integrations, migrations,
and backend tests.
reports_to: software_architect
icon: terminal
can_spawn: []
tools: []
preferred_external_agent: codex
prompt_refs:
- Preserve data and API compatibility, validate inputs, handle failure paths, and
add focused tests for changed behavior.
skill_refs:
- software-delivery
- testing-quality-gate
handoff_template_ref: null
memory_policy_ref: null
artifact_contract_ref: null
runtime_policy:
execution_strategy: external
allowed_downstream_roles: []
review_role: null
default_turn_type: work
shell_timeout_override: null
setup_env_type: null
coordination_hints: {}
signal_capabilities: []
parallelism_constraints: []
gate_preferences: {}
capabilities: []
role_type: worker
coordinator_policy: null
- id: qa_engineer
name: QA Engineer
responsibility: Independently validates requirements, regressions, edge cases, test
evidence, and release readiness.
reports_to: engineering_manager
icon: bug
can_spawn: []
tools: []
preferred_external_agent: codex
prompt_refs:
- Build a risk-based test matrix, run focused and integration checks, and report
reproducible defects with evidence.
skill_refs:
- testing-quality-gate
- code-review-gate
- code-review
- release-readiness
handoff_template_ref: null
memory_policy_ref: null
artifact_contract_ref: null
runtime_policy:
execution_strategy: auto
allowed_downstream_roles: []
review_role: null
default_turn_type: verify
shell_timeout_override: null
setup_env_type: null
coordination_hints: {}
signal_capabilities: []
parallelism_constraints: []
gate_preferences: {}
capabilities: []
role_type: worker
coordinator_policy: null
- id: devops_engineer
name: DevOps Engineer
responsibility: Owns environment setup, CI/CD, deployment, observability, rollback
planning, and operational verification.
reports_to: software_architect
icon: settings
can_spawn: []
tools: []
preferred_external_agent: codex
prompt_refs:
- Probe the environment first, keep changes reversible, document commands, and include
health checks and rollback steps.
skill_refs:
- repo-onboarding
- release-readiness
handoff_template_ref: null
memory_policy_ref: null
artifact_contract_ref: null
runtime_policy:
execution_strategy: auto
allowed_downstream_roles: []
review_role: null
default_turn_type: setup
shell_timeout_override: 1800
setup_env_type: null
coordination_hints: {}
signal_capabilities: []
parallelism_constraints: []
gate_preferences: {}
capabilities: []
role_type: worker
coordinator_policy: null
- id: security_reviewer
name: Security Reviewer
responsibility: Reviews authentication, authorization, secrets, dependencies, input
handling, privacy, and abuse risks.
reports_to: engineering_manager
icon: shield
can_spawn: []
tools: []
preferred_external_agent: codex
prompt_refs:
- Review only the relevant attack surface, rank findings by severity, and distinguish
blockers from hardening opportunities.
skill_refs:
- code-review-gate
- code-review
- release-readiness
handoff_template_ref: null
memory_policy_ref: null
artifact_contract_ref: null
runtime_policy:
execution_strategy: auto
allowed_downstream_roles: []
review_role: null
default_turn_type: review
shell_timeout_override: null
setup_env_type: null
coordination_hints: {}
signal_capabilities: []
parallelism_constraints: []
gate_preferences: {}
capabilities: []
role_type: worker
coordinator_policy: null
employees: []
escalation_rules: []
runtime_policies:
custom:
communication:
default_mode: dm
blocking_default: true
meeting_required_for:
- architecture
- cross_team_conflict
allow_broadcast: true
memory:
include_role_memory: true
include_project_memory: true
include_decision_log: true
include_artifact_index: true
recent_history_lines: 16
handoff:
require_structured_handoff: true
require_ack: true
include_risks: true
include_open_questions: true
artifact:
enforce_contract: false
require_artifact_index: true
required_kinds: []
review:
enable_work_item_gates: true
strict_gate_inference: true
require_reviewer_role: true
allow_human_override: true
gate_harness:
enabled: true
decision_mode: agent_first
default_degrade_policy: allow
auto_infer_turn_kind: true
auto_infer_gate_profile: true
max_rework_rounds_per_issue: 2
stagnation_threshold: 2
allow_pass_with_constraints: true
enable_delivery_constraints_propagation: true
agent_model: ''
agent_confidence_threshold: 0.55
fallback_to_rules_on_parse_error: true
builtin_blocker_map:
workspace_mismatch: rework_same_work_item
missing_required_artifact: rework_same_work_item
verification_missing: rework_same_work_item
dependency_not_ready: rework_same_work_item
cross_work_item_conflict: replan
external_secret_missing: await_user_decision
permission_missing: escalate
environment_capability_gap: pass_with_constraints
quality_gap: rework_same_work_item
goal_env_mismatch: replan
unresolved_user_visible_risk: pass_with_constraints
turn_kind_overrides: {}
gate_profile_overrides: {}
parallel:
auto_dispatch: true
review_gate_enabled: true
max_workers: 6
coordination:
inference_mode: llm_primary
fallback_mode: conservative
strict_gate_turn_kinds:
- verify
- deliver
mixed_gate_turn_kinds:
- synthesize
- review
- integration
allow_manager_release_for_mixed_only: true
allow_custom_signals: true
talent_templates: []
teams: []
team_runtime:
default_team_id: ''
shared_role_session_scope: team
allow_shared_role_sessions: true
seat_refresh_interval_seconds: 30
metadata: {}
installed_packages: []
role_serial_queue_enabled: true
metadata:
source: org_mode
organization_config_file: company_orgs/org_software-development_config.yaml
+17
View File
@@ -3952,6 +3952,23 @@ class OPCStore:
current = {}
if token_record is None:
current.pop(agent, None)
legacy_agent = str(
current.get("external_resume_agent_type")
or (
current.get("selected_execution_agent")
if current.get("external_resume_session_id")
else ""
)
or ""
).strip()
if legacy_agent == agent:
for key in (
"external_resume_session_id",
"external_resume_session_scope_id",
"external_resume_agent_type",
"resume_source_session",
):
current.pop(key, None)
else:
current[agent] = {
str(k): v for k, v in dict(token_record).items()
+92 -4
View File
@@ -665,6 +665,7 @@ class OPCEngine:
self.approval_engine,
task_preparer=self._build_external_agent_task,
communication=self.communication,
org_engine=self.org_engine,
)
self.secretary = SecretaryService(
llm=self.llm,
@@ -11191,6 +11192,53 @@ class OPCEngine:
if callable(reset):
await reset(tasks, payload=payload)
def _merge_company_runtime_checkpoint_payload(
self,
payload: dict[str, Any],
supplement: dict[str, Any],
) -> dict[str, Any]:
merged = copy.deepcopy(payload)
merged["task_ids"] = list(dict.fromkeys([
*list(merged.get("task_ids", []) or []),
*list(supplement.get("task_ids", []) or []),
]))
for key, identity_key in (
("task_snapshots", "task_id"),
("active_work_items", "work_item_id"),
):
indexed = {
str(item.get(identity_key, "") or "").strip(): copy.deepcopy(item)
for item in list(merged.get(key, []) or [])
if isinstance(item, dict)
and str(item.get(identity_key, "") or "").strip()
}
for item in list(supplement.get(key, []) or []):
if not isinstance(item, dict):
continue
identity = str(item.get(identity_key, "") or "").strip()
if identity:
indexed[identity] = copy.deepcopy(item)
merged[key] = list(indexed.values())
for key in ("role_runtime_session_ids", "seat_state_ids"):
merged[key] = list(dict.fromkeys([
*list(merged.get(key, []) or []),
*list(supplement.get(key, []) or []),
]))
for key in (
"native_runtime_resume",
"adapter_session_state",
"external_sessions",
"progress_tail",
):
merged[key] = {
**dict(merged.get(key, {}) or {}),
**copy.deepcopy(dict(supplement.get(key, {}) or {})),
}
merged["basis_hash"] = self._checkpoint_basis_hash(merged)
return merged
async def _load_company_suspend_checkpoint_runtime(
self,
checkpoint: ExecutionCheckpoint,
@@ -11213,16 +11261,56 @@ class OPCEngine:
task = await self.store.get_task(task_id)
if task:
tasks.append(task)
if not tasks and parent_session_id:
plan_data = payload.get("company_work_item_plan") or payload.get("plan") or {}
plan = deserialize_company_work_item_runtime_plan(plan_data if isinstance(plan_data, dict) else {})
if parent_session_id:
snapshot = await self._load_company_runtime_snapshot(parent_session_id)
if snapshot:
_plan, tasks = snapshot
snapshot_plan, current_tasks = snapshot
if not tasks:
tasks = current_tasks
if not plan.projections:
plan = snapshot_plan
else:
known_task_ids = {task.id for task in tasks}
held_task_ids = await self._company_suspend_resume_candidate_task_ids(
current_tasks
)
supplemental_tasks = [
task
for task in current_tasks
if task.id not in known_task_ids and task.id in held_task_ids
]
if supplemental_tasks:
supplement = await self._company_runtime_checkpoint_payload(
checkpoint_type=str(
checkpoint.checkpoint_type
or payload.get("checkpoint_type")
or "company_runtime_interrupted"
),
reason=str(payload.get("reason", "") or "runtime_resume_reconciliation"),
parent_session_id=parent_session_id,
origin_task_id=str(
checkpoint.task_id
or payload.get("origin_task_id")
or ""
).strip()
or None,
plan=plan,
tasks=supplemental_tasks,
stop_intent_id=str(payload.get("stop_intent_id", "") or "").strip()
or None,
)
payload = self._merge_company_runtime_checkpoint_payload(
payload,
supplement,
)
tasks.extend(supplemental_tasks)
if not tasks:
await self.store.resolve_execution_checkpoint(checkpoint.checkpoint_id, status="invalid")
return None
plan_data = payload.get("company_work_item_plan") or payload.get("plan") or {}
plan = deserialize_company_work_item_runtime_plan(plan_data if isinstance(plan_data, dict) else {})
payload["checkpoint_id"] = checkpoint.checkpoint_id
payload["checkpoint_type"] = checkpoint.checkpoint_type
return payload, parent_session_id, plan, tasks
+22 -4
View File
@@ -5461,7 +5461,18 @@ class CompanyWorkItemExecutor:
await self._emit_progress(f"[Company:{projection_id}] awaiting peer", task_id=task.id)
await self.save_task(task)
else:
await transition_work_item_from_task(
self.store,
task,
target_status_or_phase=result.status,
reason=f"work_item_{result.status.value}",
)
await self._append_progress(
task,
f"Work item ended with status {result.status.value}.",
)
await self._emit_progress(f"[Company:{projection_id}] failed", task_id=task.id)
await self.save_task(task)
return result
# Comms park check: if the agent sent any blocking messages
@@ -7332,12 +7343,19 @@ class CompanyWorkItemExecutor:
metadata_updates["review_resolution"] = copy.deepcopy(resolution)
metadata_updates["review_resolution_state"] = "pending"
try:
current = None
if hasattr(self.store, "get_delegation_work_item"):
current = await self.store.get_delegation_work_item(review_work_item_id)
update_kwargs: dict[str, Any] = {
"claimed_by_role_runtime_session_id": "",
"claimed_by_seat_id": "",
"metadata_updates": metadata_updates,
}
if current is None or current.phase not in DONE_PHASES or current.phase == phase:
update_kwargs["phase"] = phase
persisted = await self.store.update_delegation_work_item(
review_work_item_id,
phase=phase,
claimed_by_role_runtime_session_id="",
claimed_by_seat_id="",
metadata_updates=metadata_updates,
**update_kwargs,
)
except Exception:
logger.opt(exception=True).warning(
@@ -1710,6 +1710,7 @@ class CompanyRuntime:
**dict(session.resume_state),
**runtime_state,
}
await self._refresh_adapter_session_state_from_store(session)
adapter_state_updates = self._adapter_state_from_result(task, result)
if adapter_state_updates:
session.adapter_session_state = {
@@ -1899,6 +1900,31 @@ class CompanyRuntime:
return candidate
return None
async def _refresh_adapter_session_state_from_store(
self,
session: CompanyMemberSession,
) -> None:
role_session_id = str(session.role_session_id or "").strip()
getter = getattr(self.store, "get_delegation_role_session", None)
if not role_session_id or not callable(getter):
return
try:
persisted = await getter(role_session_id)
except Exception:
logger.opt(exception=True).debug(
"company runtime adapter-state refresh failed"
)
return
if persisted is None:
return
adapter_state = dict(
getattr(persisted, "adapter_session_state", {}) or {}
)
session.adapter_session_state = adapter_state
role_session = self.role_sessions.get(role_session_id)
if role_session is not None:
role_session.adapter_session_state = dict(adapter_state)
def _ensure_role_session(self, task: Task) -> DelegationRoleSession | None:
role_id = self._role_id(task)
if not role_id:
+47 -1
View File
@@ -517,7 +517,28 @@ class ExternalAgentAdapter(abc.ABC):
merged = {**os.environ, **{str(k): str(v) for k, v in extra_env.items()}}
path_value = merged.get("PATH") or merged.get("Path") or merged.get("path") or ""
resolved = shutil.which(executable, path=path_value or None)
if not resolved or resolved == executable:
if not resolved:
return cmd
if os.name == "nt":
resolved = ExternalAgentAdapter._resolve_windows_command_shim(
executable,
resolved,
path_value=path_value,
)
if os.name == "nt" and os.path.splitext(resolved)[1].lower() in {".cmd", ".bat"}:
comspec = str(os.environ.get("COMSPEC") or "").strip()
if not comspec:
comspec = shutil.which("cmd.exe") or "cmd.exe"
wrapped_cmd = [comspec, "/d", "/s", "/c", resolved, *cmd[1:]]
if isinstance(launch_metadata, dict):
launch_metadata.setdefault("configured_binary", executable)
launch_metadata["resolved_binary"] = resolved
launch_metadata["launch_wrapper"] = comspec
return wrapped_cmd
if resolved == executable:
return cmd
resolved_cmd = list(cmd)
resolved_cmd[0] = resolved
@@ -526,6 +547,31 @@ class ExternalAgentAdapter(abc.ABC):
launch_metadata["resolved_binary"] = resolved
return resolved_cmd
@staticmethod
def _resolve_windows_command_shim(
executable: str,
resolved: str,
*,
path_value: str = "",
) -> str:
"""Prefer a Windows-launchable sibling over an extensionless POSIX shim."""
if os.path.splitext(resolved)[1]:
return resolved
for extension in (".exe", ".com", ".cmd", ".bat"):
sibling = f"{resolved}{extension}"
if os.path.isfile(sibling):
return sibling
if os.path.dirname(executable):
return resolved
for extension in (".exe", ".com", ".cmd", ".bat"):
candidate = shutil.which(f"{executable}{extension}", path=path_value or None)
if candidate:
return candidate
return resolved
async def send_process_input(
self,
proc: asyncio.subprocess.Process,
+66 -1
View File
@@ -32,6 +32,8 @@ class CodexAdapter(ExternalAgentAdapter):
"CODEX_SANDBOX_NETWORK_DISABLED",
"CODEX_THREAD_ID",
}
_RUNTIME_SHELL_ENV_KEYS = {"PYTHONPATH"}
_RUNTIME_SHELL_ENV_PREFIXES = ("OPC_",)
_PROMPT_SENTINEL = "-"
_TTY_EOF = b"\x04"
_INTERACTIVE_ARGV_PROMPT_MAX_BYTES = 16 * 1024
@@ -118,6 +120,64 @@ class CodexAdapter(ExternalAgentAdapter):
normalized = key.upper()
return normalized in cls._PARENT_CODEX_RUNTIME_ENV_VARS
@classmethod
def _runtime_shell_environment_config_args(
cls,
extra_env: dict[str, str] | None,
) -> tuple[list[str], list[str]]:
"""Forward only OpenOPC's runtime contract into Codex shell tools.
Codex can intentionally run with ``shell_environment_policy.inherit=core``.
The broker process still receives ``OPC_*`` variables, but commands launched
by Codex do not unless they are added to the policy's ``set`` table.
"""
forwarded: dict[str, str] = {}
for raw_key, raw_value in dict(extra_env or {}).items():
key = str(raw_key).strip()
if not key:
continue
if key not in cls._RUNTIME_SHELL_ENV_KEYS and not key.startswith(
cls._RUNTIME_SHELL_ENV_PREFIXES
):
continue
value = str(raw_value)
if key == "OPC_ALLOWED_COLLAB_TOOLS":
try:
parsed = json.loads(value)
except json.JSONDecodeError:
parsed = None
if isinstance(parsed, list):
value = ",".join(
str(item).strip() for item in parsed if str(item).strip()
)
forwarded[key] = value
args: list[str] = []
for key in sorted(forwarded):
value = forwarded[key]
if "'''" in value:
encoded = json.dumps(value, ensure_ascii=False)
else:
encoded = f"'''{value}'''"
args.extend(["-c", f"shell_environment_policy.set.{key}={encoded}"])
return args, sorted(forwarded)
@classmethod
def _inject_runtime_shell_environment(
cls,
cmd: list[str],
extra_env: dict[str, str] | None,
launch_metadata: dict[str, Any] | None = None,
) -> list[str]:
config_args, forwarded_keys = cls._runtime_shell_environment_config_args(extra_env)
if not config_args or not cmd:
return list(cmd)
insert_at = 2 if len(cmd) > 1 and str(cmd[1]).strip() == "exec" else 1
launch_cmd = [*cmd[:insert_at], *config_args, *cmd[insert_at:]]
if isinstance(launch_metadata, dict):
launch_metadata["runtime_shell_environment_keys"] = forwarded_keys
return launch_cmd
def build_workspace_args(self, workspace_path: str | None = None) -> list[str]:
args: list[str] = []
if workspace_path:
@@ -564,8 +624,13 @@ class CodexAdapter(ExternalAgentAdapter):
prompt_transport = str((launch_metadata or {}).get("prompt_transport") or "").strip().lower()
prompt = self._prompt_text_from_task(task) if prompt_transport == "stdin" else ""
env = self.build_process_env(extra_env)
launch_cmd = self._resolve_launch_command(
runtime_cmd = self._inject_runtime_shell_environment(
cmd,
extra_env,
launch_metadata=launch_metadata,
)
launch_cmd = self._resolve_launch_command(
runtime_cmd,
extra_env=extra_env,
launch_metadata=launch_metadata,
)
+95 -33
View File
@@ -92,11 +92,13 @@ class ExternalAgentBroker:
approval_engine: ApprovalEngine,
task_preparer: Callable[[Task], Coroutine[Any, Any, Task]] | None = None,
communication: CommunicationManager | None = None,
org_engine: Any | None = None,
) -> None:
self.store = store
self.approval_engine = approval_engine
self.task_preparer = task_preparer
self.communication = communication
self.org_engine = org_engine
@staticmethod
def _normalize_external_agent_choice(value: Any) -> str:
@@ -137,19 +139,19 @@ class ExternalAgentBroker:
)
return selected
async def _provider_stream_token_allows_resume(
async def _stored_provider_token_allows_resume(
self,
*,
adapter: ExternalAgentAdapter,
task: Task,
role_session_id: str,
token: str,
) -> bool:
"""Cross-check an early stream token against its latest run status."""
) -> bool | None:
"""Return the latest durable resumability verdict for one provider token."""
list_sessions = getattr(self.store, "list_external_sessions", None)
if not callable(list_sessions):
return False
return None
project_id = str(task.project_id or "default").strip() or "default"
kwargs: dict[str, Any] = {"project_id": project_id, "limit": 100}
if role_session_id:
@@ -160,19 +162,30 @@ class ExternalAgentBroker:
sessions = await list_sessions(**kwargs)
except Exception:
logger.opt(exception=True).debug(
"External resume restore: provider-stream status lookup failed"
"External resume restore: provider-token status lookup failed"
)
return False
for session in sessions:
if str(getattr(session, "agent_type", "") or "").strip() != adapter.agent_type:
continue
if external_session_matches_provider_token(session, token):
return (
external_session_allows_resume(session)
and str(getattr(session, "status", "") or "").strip().lower()
return None
matching = [
session
for session in sessions
if str(getattr(session, "agent_type", "") or "").strip()
== adapter.agent_type
and external_session_matches_provider_token(session, token)
]
if not matching:
return None
selected, selected_token = select_best_external_resume_session(
matching,
agent_type=adapter.agent_type,
project_id=project_id,
)
return bool(
selected is not None
and selected_token == token
and external_session_allows_resume(selected)
and str(getattr(selected, "status", "") or "").strip().lower()
in {"done", "suspended"}
)
return False
@classmethod
def _task_explicitly_selected_external_agent(cls, task: Task, agent_type: str) -> bool:
@@ -474,9 +487,12 @@ class ExternalAgentBroker:
return
if str(getattr(config, "session_mode", "") or "").strip().lower() == "new":
return
# Respect already-configured resume state.
if str(getattr(config, "session_mode", "") or "").strip().lower() == "resume" and getattr(config, "session_id", ""):
return
configured_session_token = (
str(getattr(config, "session_id", "") or "").strip()
if str(getattr(config, "session_mode", "") or "").strip().lower()
== "resume"
else ""
)
store = getattr(self, "store", None)
if store is None or not hasattr(store, "get_external_session"):
@@ -494,7 +510,7 @@ class ExternalAgentBroker:
# Fix 5 PR6: canonical source is ``role_runtime_session.adapter_session_state[agent_type]``.
# Check it first; the ExternalSession table is a compatibility
# fallback for legacy rows written before PR6 landed.
session_token = ""
session_token = configured_session_token
if role_session_id and hasattr(store, "get_role_session_adapter_state"):
try:
entry = await store.get_role_session_adapter_state(
@@ -507,28 +523,29 @@ class ExternalAgentBroker:
)
entry = None
if isinstance(entry, dict):
session_token = str(
role_session_token = str(
entry.get("resume_session_id")
or entry.get("provider_session_id")
or ""
).strip()
if not is_provider_session_token(
session_token,
role_session_token,
agent_type=adapter.agent_type,
project_id=project_id,
):
session_token = ""
if (
session_token
and str(entry.get("source", "") or "").strip()
== "provider_stream"
and not await self._provider_stream_token_allows_resume(
role_session_token = ""
session_token = session_token or role_session_token
token_allows_resume = (
await self._stored_provider_token_allows_resume(
adapter=adapter,
task=task,
role_session_id=role_session_id,
token=session_token,
)
):
if session_token
else None
)
if session_token and token_allows_resume is False:
session_token = ""
clear_role_state = getattr(
store,
@@ -544,7 +561,22 @@ class ExternalAgentBroker:
)
except Exception:
logger.opt(exception=True).debug(
"External resume restore: stale provider-stream token clear failed"
"External resume restore: stale provider token clear failed"
)
if hasattr(config, "session_mode"):
config.session_mode = "new"
if hasattr(config, "session_id"):
config.session_id = ""
task.metadata = dict(task.metadata or {})
task.metadata.pop("external_resume_session_id", None)
task.metadata.pop("external_resume_session_scope_id", None)
task.metadata.pop("external_resume_agent_type", None)
task.metadata["external_resume_fallback"] = (
"provider_terminal_failure"
)
if on_progress:
await on_progress(
f"[External resume] {adapter.agent_type} skipped prior failed session"
)
# Do not immediately rediscover the same unfinalized or
# failed stream row through the compatibility fallback.
@@ -2553,19 +2585,33 @@ class ExternalAgentBroker:
and hasattr(self.store, "update_role_session_adapter_state")
):
# A stream token is durable early so Stop can retain it. A normal
# terminal failure, however, must not leave that attempt's token
# pinned for a later unrelated turn.
# terminal failure must clear both a token discovered by this task
# and an older role token that this failed attempt resumed.
try:
current = await self.store.get_role_session_adapter_state(
role_session_id,
adapter.agent_type,
)
current_token = ""
if isinstance(current, dict):
current_token = str(
current.get("resume_session_id")
or current.get("provider_session_id")
or ""
).strip()
failed_token = str(
resume_session_id
or provider_session_id
or getattr(adapter.config, "session_id", "")
or ""
).strip()
if (
isinstance(current, dict)
and str(current.get("last_task_id", "") or "").strip()
and (
str(current.get("last_task_id", "") or "").strip()
== str(task.id or "").strip()
and str(current.get("source", "") or "").strip()
== "provider_stream"
or bool(failed_token and current_token == failed_token)
)
):
await self.store.update_role_session_adapter_state(
role_session_id,
@@ -2576,3 +2622,19 @@ class ExternalAgentBroker:
logger.opt(exception=True).debug(
"Failed to clear provider-stream role state after terminal failure"
)
if result.status != TaskStatus.DONE:
failed_token = str(
resume_session_id
or provider_session_id
or getattr(adapter.config, "session_id", "")
or ""
).strip()
task_token = str(
(task.metadata or {}).get("external_resume_session_id", "") or ""
).strip()
if failed_token and task_token == failed_token:
task.metadata = dict(task.metadata or {})
task.metadata.pop("external_resume_session_id", None)
task.metadata.pop("external_resume_session_scope_id", None)
task.metadata.pop("external_resume_agent_type", None)
task.metadata["external_resume_fallback"] = "provider_terminal_failure"
+39 -10
View File
@@ -61,6 +61,23 @@ def provider_token_from_external_session(
) -> str:
if not external_session_allows_resume(session):
return ""
return _provider_token_from_session_identity(
session,
agent_type=agent_type,
project_id=project_id,
)
def _provider_token_from_session_identity(
session: Any | None,
*,
agent_type: str,
project_id: str,
) -> str:
"""Extract a provider token without treating its row as resumable."""
if session is None:
return ""
metadata = dict(getattr(session, "metadata", {}) or {})
for candidate in (
metadata.get("resume_session_id"),
@@ -108,7 +125,7 @@ def select_best_external_resume_session(
) -> tuple[Any | None, str]:
"""Select the newest valid provider capability, ignoring placeholders."""
valid: list[tuple[Any, str]] = []
latest_by_token: dict[str, Any] = {}
normalized_agent = str(agent_type or "").strip()
for session in list(sessions or []):
if (
@@ -116,23 +133,35 @@ def select_best_external_resume_session(
!= normalized_agent
):
continue
token = provider_token_from_external_session(
token = _provider_token_from_session_identity(
session,
agent_type=normalized_agent,
project_id=project_id,
)
if token:
valid.append((session, token))
if not token:
continue
current = latest_by_token.get(token)
if current is None or _session_timestamp(session) > _session_timestamp(current):
latest_by_token[token] = session
valid = [
(session, token)
for token, session in latest_by_token.items()
if external_session_allows_resume(session)
]
if not valid:
return None, ""
def _sort_key(item: tuple[Any, str]) -> tuple[float, str]:
session, token = item
updated_at = getattr(session, "updated_at", None)
try:
timestamp = float(updated_at.timestamp())
except Exception:
timestamp = 0.0
return timestamp, token
return _session_timestamp(session), token
return max(valid, key=_sort_key)
def _session_timestamp(session: Any) -> float:
updated_at = getattr(session, "updated_at", None)
try:
return float(updated_at.timestamp())
except Exception:
return 0.0
+15 -2
View File
@@ -91,6 +91,19 @@ def _ensure_aiohttp() -> None:
_pip_install(*missing)
def _run_npm(npm: str, *args: str) -> None:
"""Run npm in a way that also works when Windows resolves npm.cmd."""
cmd = [npm, *args]
if sys.platform == "win32":
npm_path = Path(npm)
npm_cmd = npm_path.with_suffix(".cmd")
if npm_path.suffix.lower() in {".cmd", ".bat"}:
cmd = [os.environ.get("COMSPEC", "cmd.exe"), "/c", npm, *args]
elif npm_cmd.exists():
cmd = [os.environ.get("COMSPEC", "cmd.exe"), "/c", str(npm_cmd), *args]
subprocess.check_call(cmd, cwd=_FRONTEND_SRC)
def _ensure_frontend() -> None:
"""Build frontend if frontend_dist is missing, empty, or stale."""
if not _frontend_needs_rebuild():
@@ -114,11 +127,11 @@ def _ensure_frontend() -> None:
node_modules = _FRONTEND_SRC / "node_modules"
if not node_modules.is_dir():
terminal_status("Installing npm dependencies")
subprocess.check_call([npm, "install"], cwd=_FRONTEND_SRC)
_run_npm(npm, "install")
# npm run build → outputs to ../frontend_dist via vite config
terminal_status("Building frontend bundle")
subprocess.check_call([npm, "run", "build"], cwd=_FRONTEND_SRC)
_run_npm(npm, "run", "build")
if _FRONTEND_DIST.is_dir() and any(_FRONTEND_DIST.iterdir()):
terminal_status("Frontend built successfully", kind="success")
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,12 +5,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:," />
<title>OpenOPC Pixel Office</title>
<script type="module" crossorigin src="./assets/index-CTK-c7_K.js"></script>
<script type="module" crossorigin src="./assets/index-E3Xtv5nT.js"></script>
<link rel="modulepreload" crossorigin href="./assets/phaser-DFK5Ua9d.js">
<link rel="stylesheet" crossorigin href="./assets/index-DEvLDWDw.css">
<link rel="stylesheet" crossorigin href="./assets/index-B6-ikSHW.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+71 -47
View File
@@ -27,6 +27,7 @@ import { extractSessionRecruitmentByRole, sessionChannelId } from './lib/session
import { resolveCanonicalTurnId, terminalAssistantTurnId } from './lib/turnIdentity'
import { unassignAgent } from './game/map/OfficeStore'
import type { AgentAnimStatus, EmployeeAssignment, KanbanPhase, KanbanTask, RoleAggregatedStatus, RoleWorkItemSummary, Session, TaskPreferredAgent } from './types/kanban'
import { useI18n } from './i18n'
function readOutdoorOverrideUi(): 'auto' | 'day' | 'night' {
try {
@@ -444,6 +445,7 @@ function MaybeExecutionPanel({ taskId, sessions, agents, onClose }: {
}
export default function App() {
const { locale, setLocale, t, translateMaybe } = useI18n()
const bridgeRef = useRef(new GameBridge())
useMemo(() => registerTestRunner(bridgeRef.current), [])
const clientRef = useRef<VisualSocketClient | null>(null)
@@ -2347,26 +2349,47 @@ export default function App() {
<div className="topbar-center">
<div className="page-nav">
<button className={`page-nav-btn${activePage === 'workspace' ? ' active' : ''}`} onClick={() => setActivePage('workspace')}>
Workspace
{t('app.page.workspace')}
{(() => {
const total = chatStore.channels.reduce((sum, ch) => sum + chatStore.getUnreadCount(ch.id), 0)
return total > 0 ? <span className="nav-unread-badge">{total > 99 ? '99+' : total}</span> : null
})()}
</button>
<button className={`page-nav-btn${activePage === 'office' ? ' active' : ''}`} onClick={() => setActivePage('office')}>Office</button>
<button className={`page-nav-btn${activePage === 'org' ? ' active' : ''}`} onClick={() => setActivePage('org')}>Org</button>
<button className={`page-nav-btn${activePage === 'office' ? ' active' : ''}`} onClick={() => setActivePage('office')}>{t('app.page.office')}</button>
<button className={`page-nav-btn${activePage === 'org' ? ' active' : ''}`} onClick={() => setActivePage('org')}>{t('app.page.org')}</button>
</div>
<div className="stat-chips">
<span className="stat-chip"><b>{metrics.totalAgents}</b> agents</span>
<span className="stat-chip"><b>{metrics.totalSkills}</b> skills</span>
<span className="stat-chip"><b>{boardStore.getOpenTaskCount()}</b> tasks</span>
<span className="stat-chip"><b>{metrics.totalAgents}</b> {t('app.metric.agents')}</span>
<span className="stat-chip"><b>{metrics.totalSkills}</b> {t('app.metric.skills')}</span>
<span className="stat-chip"><b>{boardStore.getOpenTaskCount()}</b> {t('app.metric.tasks')}</span>
</div>
</div>
<div className="topbar-right">
<div className="language-toggle" role="group" aria-label={t('language.label')} title={t('language.label')}>
<button
type="button"
data-locale="en"
aria-pressed={locale === 'en'}
className={`language-toggle-btn${locale === 'en' ? ' active' : ''}`}
onClick={() => setLocale('en')}
>
{t('language.english')}
</button>
<button
type="button"
data-locale="zh-CN"
aria-pressed={locale === 'zh-CN'}
className={`language-toggle-btn${locale === 'zh-CN' ? ' active' : ''}`}
onClick={() => setLocale('zh-CN')}
>
{t('language.chinese')}
</button>
</div>
<select
className="theme-select"
value={outdoorOverride}
title="Outdoor lighting override"
title={t('outdoor.title')}
aria-label={t('outdoor.title')}
onChange={(e) => {
const v = e.target.value as 'auto' | 'day' | 'night'
setOutdoorOverride(v)
@@ -2384,20 +2407,20 @@ export default function App() {
bridgeRef.current.syncOutdoorLighting()
}}
>
<option value="auto">Outdoor auto</option>
<option value="day">Outdoor day</option>
<option value="night">Outdoor night</option>
<option value="auto">{t('outdoor.auto')}</option>
<option value="day">{t('outdoor.day')}</option>
<option value="night">{t('outdoor.night')}</option>
</select>
<select className="theme-select" value={theme} onChange={(e) => setTheme(e.target.value as ThemeName)}>
<option value="midnight">Midnight</option>
<option value="neon">Neon</option>
<option value="paper">Paper</option>
<option value="retro">Retro</option>
<option value="terminal">Terminal</option>
<option value="cozy">Cozy</option>
<option value="openopc">OpenOPC</option>
<option value="midnight">{t('theme.midnight')}</option>
<option value="neon">{t('theme.neon')}</option>
<option value="paper">{t('theme.paper')}</option>
<option value="retro">{t('theme.retro')}</option>
<option value="terminal">{t('theme.terminal')}</option>
<option value="cozy">{t('theme.cozy')}</option>
<option value="openopc">{t('theme.openopc')}</option>
</select>
<button className={`icon-btn ${showDevTools ? 'active' : ''}`} onClick={() => setShowDevTools((v) => !v)} title="Developer Tools">
<button className={`icon-btn ${showDevTools ? 'active' : ''}`} onClick={() => setShowDevTools((v) => !v)} title={t('dev.tools')} aria-label={t('dev.tools')}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M5.5 2L2 5.5 5.5 9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/><path d="M10.5 7L14 10.5 10.5 14" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
</button>
</div>
@@ -2552,14 +2575,14 @@ export default function App() {
{/* Phaser Game Canvas */}
<section className="canvas-wrap">
<PhaserGame bridge={bridgeRef.current} active={activePage === 'office'} />
<button className="canvas-float-btn" onClick={() => setShowSubagents((v) => !v)} title={showSubagents ? 'Hide sub-agents' : 'Show sub-agents'}>
<button className="canvas-float-btn" onClick={() => setShowSubagents((v) => !v)} title={showSubagents ? t('office.hideSubagents') : t('office.showSubagents')}>
{showSubagents ? '👥' : '👤'}
</button>
<button
className="sidebar-collapse-btn"
onClick={toggleSidebar}
title={sidebarCollapsed ? 'Show side panel' : 'Hide side panel'}
aria-label={sidebarCollapsed ? 'Show side panel' : 'Hide side panel'}
title={sidebarCollapsed ? t('office.showSidePanel') : t('office.hideSidePanel')}
aria-label={sidebarCollapsed ? t('office.showSidePanel') : t('office.hideSidePanel')}
>
<span className="collapse-glyph">{sidebarCollapsed ? '' : ''}</span>
</button>
@@ -2574,13 +2597,13 @@ export default function App() {
<div className="mode-info-bar">
<span className="mode-badge">{globalExecMode === 'company' ? `${globalExecMode}/${globalCompanyProfile}` : globalModeLabel}</span>
{isOrgMode ? (
<span className="mode-hint">Manage your team in the <b>Org</b> tab</span>
<span className="mode-hint">{t('office.modeHint.org')}</span>
) : (
<span className="mode-hint">Switch to <b>Org</b> mode to create or manage agents</span>
<span className="mode-hint">{t('office.modeHint.switch')}</span>
)}
</div>
<div className="section-label">Offices <span className="count-badge">{offices.length}</span></div>
<div className="section-label">{t('office.offices')} <span className="count-badge">{offices.length}</span></div>
<div className="office-cards">
{offices.map((office) => {
const deskCount = getOfficeDeskSeats(office.id).length
@@ -2602,14 +2625,14 @@ export default function App() {
) : (
<>
<span className="office-name">{office.name}</span>
<button className="office-edit-btn" title="Rename" onClick={(e) => { e.stopPropagation(); setEditingOfficeName(office.id); setOfficeNameDraft(office.name) }}></button>
<button className="office-edit-btn" title={t('office.rename')} onClick={(e) => { e.stopPropagation(); setEditingOfficeName(office.id); setOfficeNameDraft(office.name) }}></button>
</>
)}
<span className="office-capacity">{assignedCards.length}/{deskCount}</span>
</div>
<div className="office-agents">
{assignedCards.map(c => (
<span key={c.id} className="office-agent-chip" title={`${c.displayName}${c.seatId ?? 'no seat'}`} onClick={(e) => { e.stopPropagation(); selectAgent(c.id) }}>
<span key={c.id} className="office-agent-chip" title={`${c.displayName}${c.seatId ?? t('office.noSeat')}`} onClick={(e) => { e.stopPropagation(); selectAgent(c.id) }}>
{c.displayName.slice(0, 8)}
</span>
))}
@@ -2620,7 +2643,7 @@ export default function App() {
onClick={e => e.stopPropagation()}
onChange={e => { if (e.target.value) handleAssignAgent(office.id, e.target.value) }}
>
<option value="">+ Move here</option>
<option value="">{t('office.moveHere')}</option>
{otherAgents.map(a => (
<option key={a.id} value={a.id}>{a.displayName} ({offices.find(o => o.id === (cards.find(cc => cc.id === a.id)?.officeId))?.name ?? '?'})</option>
))}
@@ -2632,7 +2655,7 @@ export default function App() {
})}
</div>
<div className="section-label">Active Agents <span className="count-badge">{swarmAgents.length}</span></div>
<div className="section-label">{t('office.activeAgents')} <span className="count-badge">{swarmAgents.length}</span></div>
<div className="agent-list">
{swarmAgents.map((agent) => (
<div key={agent.agent_id} className={`agent-row ${selectedAgentId === agent.agent_id ? 'selected' : ''}`}>
@@ -2640,7 +2663,7 @@ export default function App() {
<span className={`dot ${agent.status}`} />
<div className="agent-info">
<span className="agent-name">{agent.name}</span>
<span className="agent-spec">{agent.specialties.slice(0, 2).join(' · ') || 'general'}</span>
<span className="agent-spec">{agent.specialties.slice(0, 2).join(' · ') || t('common.general')}</span>
</div>
</button>
{isOrgMode && (
@@ -2648,27 +2671,27 @@ export default function App() {
? <span className="agent-del" style={{ pointerEvents: 'none' }}><span className="spinner-inline" /></span>
: confirmDeleteId === agent.agent_id
? <span className="del-confirm">
<span className="del-confirm-label">Delete?</span>
<button className="del-confirm-yes" onClick={() => { setDeletingAgentId(agent.agent_id); setConfirmDeleteId(null); clientRef.current?.deleteAgent(agent.agent_id) }}>Yes</button>
<button className="del-confirm-no" onClick={() => setConfirmDeleteId(null)}>No</button>
<span className="del-confirm-label">{t('office.deleteQuestion')}</span>
<button className="del-confirm-yes" onClick={() => { setDeletingAgentId(agent.agent_id); setConfirmDeleteId(null); clientRef.current?.deleteAgent(agent.agent_id) }}>{t('common.yes')}</button>
<button className="del-confirm-no" onClick={() => setConfirmDeleteId(null)}>{t('common.no')}</button>
</span>
: <button className="agent-del" title={`Remove ${agent.name}`} onClick={() => setConfirmDeleteId(agent.agent_id)}>×</button>
: <button className="agent-del" title={t('office.removeAgent', { name: agent.name })} onClick={() => setConfirmDeleteId(agent.agent_id)}>×</button>
)}
</div>
))}
{swarmAgents.length === 0 && (
<div className="empty-state">No agents yet click a template above to spawn one.</div>
<div className="empty-state">{t('office.emptyAgents')}</div>
)}
</div>
{selectedCard && (
<div className="agent-detail">
<div className="agent-detail-name">{selectedCard.displayName}</div>
<div className="agent-detail-row"><span className="detail-label">State</span><span className="detail-value">{selectedCard.state}</span></div>
<div className="agent-detail-row"><span className="detail-label">Tool</span><span className="detail-value">{selectedCard.currentTool ?? '—'}</span></div>
<div className="agent-detail-row"><span className="detail-label">Task</span><span className="detail-value">{selectedCard.taskSummary ?? '—'}</span></div>
<div className="agent-detail-row"><span className="detail-label">{t('common.state')}</span><span className="detail-value">{translateMaybe('agent.status', selectedCard.state) || selectedCard.state}</span></div>
<div className="agent-detail-row"><span className="detail-label">{t('common.tool')}</span><span className="detail-value">{selectedCard.currentTool ?? '—'}</span></div>
<div className="agent-detail-row"><span className="detail-label">{t('common.task')}</span><span className="detail-value">{selectedCard.taskSummary ?? '—'}</span></div>
<div className="agent-detail-row">
<span className="detail-label">Office</span>
<span className="detail-label">{t('app.page.office')}</span>
<select
className="detail-select"
value={selectedCard.officeId}
@@ -2679,7 +2702,7 @@ export default function App() {
</select>
</div>
<div className="agent-detail-row">
<span className="detail-label">Seat</span>
<span className="detail-label">{t('common.seat')}</span>
<select
className="detail-select"
value={selectedCard.seatId ?? ''}
@@ -2704,8 +2727,8 @@ export default function App() {
{cards.length > swarmAgents.length && (
<>
<div className="section-label">
Characters
<button className="inline-btn" onClick={() => setShowSubagents((v) => !v)}>{showSubagents ? 'hide sub' : 'show sub'}</button>
{t('office.characters')}
<button className="inline-btn" onClick={() => setShowSubagents((v) => !v)}>{showSubagents ? t('office.hideSub') : t('office.showSub')}</button>
</div>
<div className="agent-list">
{cards.filter((c) => !swarmAgents.some((a) => a.agent_id === c.id)).map((card) => (
@@ -2727,28 +2750,29 @@ export default function App() {
{showDevTools && (
<div className="dev-overlay">
<div className="dev-header">
<span className="dev-title">Developer Tools</span>
<span className="dev-title">{t('dev.tools')}</span>
<button className="icon-btn" onClick={() => setShowDevTools(false)}></button>
</div>
<div className="dev-group">
<div className="dev-label">Connection</div>
<div className="dev-label">{t('dev.connection')}</div>
<div className="input-row">
<input value={wsUrlInput} onChange={(e) => setWsUrlInput(e.target.value)} placeholder="ws://..." />
<button className="send-btn" onClick={applyWsUrl}></button>
</div>
</div>
<div className="dev-group">
<div className="dev-label">Evolution Pipeline</div>
<div className="dev-label">{t('dev.evolution')}</div>
<div className="evo-pipeline">
{(['Trace', 'Reflect', 'Synthesize', 'Practice', 'Lifecycle'] as const).map((phase, i) => {
const key = phase.toLowerCase() as keyof typeof evolutionPhases
const active = key in evolutionPhases ? evolutionPhases[key as 'trace' | 'reflect' | 'synthesize'] : false
const phaseLabelKey = `dev.phase.${key}` as Parameters<typeof t>[0]
return (
<div key={phase} className="evo-phase-group">
{i > 0 && <div className="evo-connector" />}
<div className={`evo-node ${active ? 'active' : ''}`}>
<div className="evo-dot" />
<span className="evo-label">{phase}</span>
<span className="evo-label">{t(phaseLabelKey)}</span>
</div>
</div>
)
@@ -2765,7 +2789,7 @@ export default function App() {
</div>
<div className="dev-group">
<div className="dev-label">
Events
{t('dev.events')}
<select className="inline-select" value={eventTypeFilter} onChange={(e) => setEventTypeFilter(e.target.value)}>
{eventTypes.map((type) => <option key={type} value={type}>{type}</option>)}
</select>
@@ -2783,7 +2807,7 @@ export default function App() {
</div>
{Object.keys(snapshot?.channels ?? {}).length > 0 && (
<div className="dev-group">
<div className="dev-label">Channels</div>
<div className="dev-label">{t('dev.channels')}</div>
{Object.entries(snapshot?.channels ?? {}).map(([name, info]) => (
<div className="list-row" key={name}>
<span>{name}</span>
@@ -4,6 +4,7 @@ import type { Session } from '../types/kanban'
import { IconPlus, IconSearch, IconActivity, IconShield, IconTrash, IconWorkItem } from './SvgIcons'
import { getSessionRuntimeStatus } from '../lib/sessionRuntime'
import { deriveCompanyRuntimeDisplayStatus } from '../lib/workItemSessions'
import { useI18n } from '../i18n'
interface SessionSidebarProps {
sessions: Session[]
@@ -35,12 +36,14 @@ function sessionDotClass(session: Session): string {
return STATUS_DOT[displayStatus] ?? 'status-pending'
}
function relativeTime(ts: number): string {
type TFunction = ReturnType<typeof useI18n>['t']
function relativeTime(ts: number, t: TFunction): string {
const diff = Date.now() - ts
if (diff < 60_000) return 'just now'
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m`
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h`
return `${Math.floor(diff / 86_400_000)}d`
if (diff < 60_000) return t('session.justNow')
if (diff < 3_600_000) return t('session.minutes', { count: Math.floor(diff / 60_000) })
if (diff < 86_400_000) return t('session.hours', { count: Math.floor(diff / 3_600_000) })
return t('session.days', { count: Math.floor(diff / 86_400_000) })
}
function dateGroup(ts: number): string {
@@ -53,6 +56,15 @@ function dateGroup(ts: number): string {
return 'Earlier'
}
function groupLabel(group: 'Today' | 'Yesterday' | 'Earlier', hasRuntimeSessions: boolean, t: TFunction): string {
const label = group === 'Today'
? t('session.today')
: group === 'Yesterday'
? t('session.yesterday')
: t('session.earlier')
return hasRuntimeSessions ? `${label} ${t('session.runtimeSuffix')}` : label
}
interface SessionTree {
session: Session
children: Session[]
@@ -104,6 +116,7 @@ function SessionItem({
onSelect: () => void
onDelete: () => void
}) {
const { t, translateMaybe } = useI18n()
const [showDelete, setShowDelete] = useState(false)
const [confirming, setConfirming] = useState(false)
const agentLabel = session.assigneeIds.length > 0
@@ -134,12 +147,12 @@ function SessionItem({
<span className="session-agent-tag">{agentLabel}</span>
)}
{!isChild && session.isCompanyRuntime && (
<span className="session-runtime-badge" title="Company runtime"><IconWorkItem /></span>
<span className="session-runtime-badge" title={t('session.companyRuntime')}><IconWorkItem /></span>
)}
{session.title}
</span>
<span className="session-item-meta">
{displayStatus} · {relativeTime(session.updatedAt)}
{translateMaybe('kanban.status', displayStatus) || displayStatus} · {relativeTime(session.updatedAt, t)}
</span>
</div>
{!!unreadCount && unreadCount > 0 && (
@@ -149,15 +162,15 @@ function SessionItem({
<button
className="session-delete-btn"
onClick={e => { e.stopPropagation(); setConfirming(true) }}
title="Delete"
title={t('common.delete')}
>
<IconTrash />
</button>
)}
{confirming && (
<span className="session-confirm-delete" onClick={e => e.stopPropagation()}>
<button className="session-confirm-yes" onClick={() => { setConfirming(false); onDelete() }}>Delete</button>
<button className="session-confirm-no" onClick={() => setConfirming(false)}>Cancel</button>
<button className="session-confirm-yes" onClick={() => { setConfirming(false); onDelete() }}>{t('common.delete')}</button>
<button className="session-confirm-no" onClick={() => setConfirming(false)}>{t('common.cancel')}</button>
</span>
)}
</button>
@@ -165,6 +178,7 @@ function SessionItem({
}
export function SessionSidebar({ sessions, activeSessionId, activeChannel, secretaryChannelId, unreadCounts, onSelect, onCreateSession, onDeleteSession, onSelectSecretary }: SessionSidebarProps) {
const { t } = useI18n()
const [search, setSearch] = useState('')
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
const hasRuntimeSessions = sessions.some(session =>
@@ -256,7 +270,7 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
e.stopPropagation()
toggleCollapse(node.session.taskId)
}}
aria-label={isCollapsed ? 'Expand' : 'Collapse'}
aria-label={isCollapsed ? t('session.expand') : t('session.collapse')}
/>
)}
<SessionItem
@@ -269,13 +283,13 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
</div>
</div>
)
}, [activeSessionId, collapsed, onDeleteSession, onSelect, toggleCollapse, unreadCounts])
}, [activeSessionId, collapsed, onDeleteSession, onSelect, toggleCollapse, unreadCounts, t])
const renderVirtualRow = useCallback((row: SidebarRow) => {
if (row.kind === 'group') {
return (
<div className="session-group-label">
{hasRuntimeSessions ? `${row.group} Runtime Sessions` : row.group}
{groupLabel(row.group, hasRuntimeSessions, t)}
</div>
)
}
@@ -288,7 +302,7 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
className="session-child-count"
onClick={() => toggleCollapse(row.node.session.taskId)}
>
{row.node.children.length} sub-task{row.node.children.length > 1 ? 's' : ''}
{t(row.node.children.length > 1 ? 'session.subTasks' : 'session.subTask', { count: row.node.children.length })}
</button>
)
}
@@ -305,13 +319,13 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
/>
</div>
)
}, [activeSessionId, hasRuntimeSessions, onDeleteSession, onSelect, renderPrimaryRow, toggleCollapse, unreadCounts])
}, [activeSessionId, hasRuntimeSessions, onDeleteSession, onSelect, renderPrimaryRow, toggleCollapse, unreadCounts, t])
return (
<div className="session-sidebar">
<button className="session-new-btn" onClick={onCreateSession}>
<IconPlus />
<span>New Chat</span>
<span>{t('session.newChat')}</span>
</button>
{onSelectSecretary && (
@@ -320,7 +334,7 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
onClick={onSelectSecretary}
>
<IconShield />
<span>Secretary</span>
<span>{t('session.secretary')}</span>
{!!(secretaryChannelId && unreadCounts?.[secretaryChannelId]) && unreadCounts![secretaryChannelId!] > 0 && (
<span className="session-unread-badge">{unreadCounts![secretaryChannelId!] > 99 ? '99+' : unreadCounts![secretaryChannelId!]}</span>
)}
@@ -331,7 +345,7 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
<IconSearch />
<input
className="session-search"
placeholder="Search..."
placeholder={t('session.search')}
value={search}
onChange={e => setSearch(e.target.value)}
/>
@@ -368,7 +382,7 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
if (!items || items.length === 0) return null
return (
<div key={group} className="session-group">
<div className="session-group-label">{hasRuntimeSessions ? `${group} Runtime Sessions` : group}</div>
<div className="session-group-label">{groupLabel(group, hasRuntimeSessions, t)}</div>
{items.map(node => {
const hasChildren = node.children.length > 0
const isCollapsed = collapsed.has(node.session.taskId)
@@ -382,7 +396,7 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
e.stopPropagation()
toggleCollapse(node.session.taskId)
}}
aria-label={isCollapsed ? 'Expand' : 'Collapse'}
aria-label={isCollapsed ? t('session.expand') : t('session.collapse')}
/>
)}
<SessionItem
@@ -416,7 +430,7 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
className="session-child-count"
onClick={() => toggleCollapse(node.session.taskId)}
>
{node.children.length} sub-task{node.children.length > 1 ? 's' : ''}
{t(node.children.length > 1 ? 'session.subTasks' : 'session.subTask', { count: node.children.length })}
</button>
)}
</div>
@@ -427,7 +441,7 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
})}
{filtered.length === 0 && (
<div className="session-empty">No sessions yet</div>
<div className="session-empty">{t('session.noSessions')}</div>
)}
</div>
@@ -436,7 +450,7 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
onClick={() => onSelect(null)}
>
<IconActivity />
<span>Activity</span>
<span>{t('session.activity')}</span>
</button>
</div>
)
@@ -1,5 +1,6 @@
import { useState, useCallback } from 'react'
import type { Project } from '../types/kanban'
import { useI18n } from '../i18n'
interface ProjectSelectorProps {
projects: Project[]
@@ -10,6 +11,7 @@ interface ProjectSelectorProps {
}
export function ProjectSelector({ projects, activeId, onSelect, onCreate, onDelete }: ProjectSelectorProps) {
const { t } = useI18n()
const [creating, setCreating] = useState(false)
const [newName, setNewName] = useState('')
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
@@ -34,7 +36,8 @@ export function ProjectSelector({ projects, activeId, onSelect, onCreate, onDele
className="theme-select"
value={activeId}
onChange={e => onSelect(e.target.value)}
title="Switch project"
title={t('project.switch')}
aria-label={t('project.switch')}
>
{projects.map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
@@ -49,7 +52,7 @@ export function ProjectSelector({ projects, activeId, onSelect, onCreate, onDele
autoFocus
className="theme-select"
value={newName}
placeholder="project-name"
placeholder={t('project.placeholder')}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => { if (e.key === 'Escape') setCreating(false) }}
style={{ width: 120 }}
@@ -60,7 +63,7 @@ export function ProjectSelector({ projects, activeId, onSelect, onCreate, onDele
<button
className="pill-btn"
onClick={() => setCreating(true)}
title="New project"
title={t('project.new')}
style={{ fontSize: 11, padding: '2px 8px' }}
>
+
@@ -70,10 +73,10 @@ export function ProjectSelector({ projects, activeId, onSelect, onCreate, onDele
<button
className="pill-btn"
onClick={() => setConfirmDelete(activeId)}
title="Delete project"
title={t('project.delete')}
style={{ fontSize: 11, padding: '2px 8px', color: '#ef4444' }}
>
Del
{t('project.deleteButton')}
</button>
)}
@@ -88,10 +91,10 @@ export function ProjectSelector({ projects, activeId, onSelect, onCreate, onDele
maxWidth: 400, boxShadow: '0 8px 32px rgba(0,0,0,0.4)', textAlign: 'center',
}}>
<p style={{ margin: '0 0 8px', fontWeight: 600, fontSize: 15 }}>
Delete project "{confirmDelete}"?
{t('project.confirmTitle', { name: confirmDelete })}
</p>
<p style={{ margin: '0 0 20px', fontSize: 13, opacity: 0.7 }}>
All sessions, messages, tasks, and agent data in this project will be permanently deleted. This action cannot be undone.
{t('project.confirmBody')}
</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
<button
@@ -99,14 +102,14 @@ export function ProjectSelector({ projects, activeId, onSelect, onCreate, onDele
onClick={() => setConfirmDelete(null)}
style={{ padding: '6px 18px', fontSize: 13 }}
>
Cancel
{t('common.cancel')}
</button>
<button
className="pill-btn"
onClick={handleDelete}
style={{ padding: '6px 18px', fontSize: 13, background: '#ef4444', color: '#fff' }}
>
Delete
{t('common.delete')}
</button>
</div>
</div>
@@ -0,0 +1,403 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
export type Locale = 'en' | 'zh-CN'
const STORAGE_KEY = 'openopc_locale'
const en = {
'app.page.workspace': 'Workspace',
'app.page.office': 'Office',
'app.page.org': 'Org',
'app.page.mapEditor': 'Map editor',
'app.metric.agents': 'agents',
'app.metric.skills': 'skills',
'app.metric.tasks': 'tasks',
'language.label': 'Language',
'language.english': 'EN',
'language.chinese': '简',
'outdoor.title': 'Outdoor lighting override',
'outdoor.auto': 'Outdoor auto',
'outdoor.day': 'Outdoor day',
'outdoor.night': 'Outdoor night',
'theme.midnight': 'Midnight',
'theme.neon': 'Neon',
'theme.paper': 'Paper',
'theme.retro': 'Retro',
'theme.terminal': 'Terminal',
'theme.cozy': 'Cozy',
'theme.openopc': 'OpenOPC',
'dev.tools': 'Developer Tools',
'dev.connection': 'Connection',
'dev.evolution': 'Evolution Pipeline',
'dev.events': 'Events',
'dev.channels': 'Channels',
'dev.phase.trace': 'Trace',
'dev.phase.reflect': 'Reflect',
'dev.phase.synthesize': 'Synthesize',
'dev.phase.practice': 'Practice',
'dev.phase.lifecycle': 'Lifecycle',
'project.switch': 'Switch project',
'project.new': 'New project',
'project.delete': 'Delete project',
'project.deleteButton': 'Del',
'project.placeholder': 'project-name',
'project.confirmTitle': 'Delete project "{name}"?',
'project.confirmBody': 'All sessions, messages, tasks, and agent data in this project will be permanently deleted. This action cannot be undone.',
'common.cancel': 'Cancel',
'common.delete': 'Delete',
'common.yes': 'Yes',
'common.no': 'No',
'common.loading': 'Loading...',
'common.ready': 'ready',
'common.autoSaved': 'Auto-saved',
'common.active': 'active',
'common.idle': 'idle',
'common.general': 'general',
'common.state': 'State',
'common.tool': 'Tool',
'common.task': 'Task',
'common.seat': 'Seat',
'common.role': 'Role',
'common.employee': 'Employee',
'common.manager': 'Manager',
'common.origin': 'Origin',
'common.projection': 'Projection',
'common.gate': 'Gate',
'common.runtime': 'Runtime',
'office.modeHint.org': 'Manage your team in the Org tab',
'office.modeHint.switch': 'Switch to Org mode to create or manage agents',
'office.offices': 'Offices',
'office.activeAgents': 'Active Agents',
'office.rename': 'Rename',
'office.noSeat': 'no seat',
'office.moveHere': '+ Move here',
'office.characters': 'Characters',
'office.hideSub': 'hide sub',
'office.showSub': 'show sub',
'office.hideSubagents': 'Hide sub-agents',
'office.showSubagents': 'Show sub-agents',
'office.showSidePanel': 'Show side panel',
'office.hideSidePanel': 'Hide side panel',
'office.removeAgent': 'Remove {name}',
'office.deleteQuestion': 'Delete?',
'office.emptyAgents': 'No agents yet — click a template above to spawn one.',
'session.newChat': 'New Chat',
'session.secretary': 'Secretary',
'session.search': 'Search...',
'session.activity': 'Activity',
'session.noSessions': 'No sessions yet',
'session.today': 'Today',
'session.yesterday': 'Yesterday',
'session.earlier': 'Earlier',
'session.runtimeSuffix': 'Runtime Sessions',
'session.justNow': 'just now',
'session.minutes': '{count}m',
'session.hours': '{count}h',
'session.days': '{count}d',
'session.companyRuntime': 'Company runtime',
'session.expand': 'Expand',
'session.collapse': 'Collapse',
'session.subTask': '{count} sub-task',
'session.subTasks': '{count} sub-tasks',
'workspace.selectRuntimeSession': 'Select a Runtime Session on the left to view its Work Item board.',
'workspace.noWorkItems': 'No work items yet — start delegation to populate this board.',
'workspace.clickEditTitle': 'Click to edit session title',
'workspace.channel.secretary': 'Secretary',
'workspace.channel.activity': 'Activity',
'workspace.confirmNoRoles': 'Your org has no roles defined.\n\nSet up at least one role before running a task.\n\nGo to Org tab now?',
'workspace.confirmNoDecider': 'Your organization has multiple top-level roles but no final decider selected.\n\nChoose one final decider in the Org tab before running a task.\n\nGo to Org tab now?',
'workspace.warningNoTeams': 'No runtime teams defined — the system will auto-generate from your roles',
'workspace.warningVacantRoles': '{count} role(s) have no employees: {names}',
'workspace.confirmWarnings': 'Before running this task:\n\n{warnings}\n\nRun anyway?',
'kanban.addTask': 'Add task',
'kanban.taskTitle': 'Task title...',
'kanban.startTask': 'Start task',
'kanban.startWorkItem': 'Start Work Item',
'kanban.upstreamDeps': '{count} upstream dep(s)',
'kanban.dep': '{count} dep',
'kanban.crossOffice': 'Cross-office',
'kanban.executionTurn': 'Execution Turn: {id}',
'kanban.column.Todo': 'Todo',
'kanban.column.In Progress': 'In Progress',
'kanban.column.Done': 'Done',
'kanban.column.Ready': 'Ready',
'kanban.column.Running': 'Running',
'kanban.column.Review': 'Review',
'kanban.column.Approved': 'Approved',
'kanban.status.todo': 'To do',
'kanban.status.in_progress': 'In progress',
'kanban.status.in_review': 'In review',
'kanban.status.done': 'Done',
'kanban.status.running': 'Running',
'kanban.status.idle': 'Idle',
'kanban.status.blocked': 'Blocked',
'kanban.status.awaiting_peer': 'Awaiting',
'kanban.status.awaiting_manager_review': 'Mgr Review',
'kanban.status.awaiting_human': 'Human Review',
'kanban.status.awaiting_review': 'In Review',
'kanban.status.failed': 'Failed',
'kanban.status.cancelled': 'Cancelled',
'agent.status.idle': 'Idle',
'agent.status.reflecting': 'Thinking...',
'agent.status.tool_active': 'Running tool',
'agent.summary.active': '{active}/{total} active',
'agent.summary.count': '{count} agent',
'agent.summary.countPlural': '{count} agents',
'org.loading': 'Loading organization data...',
'org.company': 'Company',
'org.customOrg': 'Custom org',
'org.corporateCompany': 'Corporate company',
'org.kind.saved': 'Saved org',
'org.kind.corporate': 'Corporate',
'org.state.editableSaved': 'Editable saved architecture',
'org.state.editableDraft': 'Editable draft architecture',
'org.state.readOnly': 'Built-in read-only architecture',
'org.organization': 'Organization',
'org.corporate': 'Corporate',
'org.newOrganization': 'New organization',
'org.roles': 'roles',
'org.employees': 'employees',
'org.runtimeTeams': 'runtime teams',
'org.tab.team': 'Team',
'org.tab.runtime': 'Runtime',
'org.tab.architecture': 'Architecture',
'org.tab.employees': 'Employees',
'org.createdToast': 'Created {name} and saved automatically',
'org.architectureApplied': 'Architecture applied successfully',
}
type MessageKey = keyof typeof en
const zh: Record<MessageKey, string> = {
'app.page.workspace': '工作台',
'app.page.office': '办公室',
'app.page.org': '组织',
'app.page.mapEditor': '地图编辑',
'app.metric.agents': 'Agent',
'app.metric.skills': '技能',
'app.metric.tasks': '任务',
'language.label': '语言',
'language.english': 'EN',
'language.chinese': '简',
'outdoor.title': '户外光照',
'outdoor.auto': '户外自动',
'outdoor.day': '户外白天',
'outdoor.night': '户外夜晚',
'theme.midnight': '午夜',
'theme.neon': '霓虹',
'theme.paper': '纸张',
'theme.retro': '复古',
'theme.terminal': '终端',
'theme.cozy': '舒适',
'theme.openopc': 'OpenOPC',
'dev.tools': '开发工具',
'dev.connection': '连接',
'dev.evolution': '进化流水线',
'dev.events': '事件',
'dev.channels': '频道',
'dev.phase.trace': '追踪',
'dev.phase.reflect': '反思',
'dev.phase.synthesize': '合成',
'dev.phase.practice': '练习',
'dev.phase.lifecycle': '生命周期',
'project.switch': '切换项目',
'project.new': '新建项目',
'project.delete': '删除项目',
'project.deleteButton': '删',
'project.placeholder': '项目名',
'project.confirmTitle': '删除项目“{name}”?',
'project.confirmBody': '此项目中的所有会话、消息、任务和 Agent 数据都会被永久删除。此操作无法撤销。',
'common.cancel': '取消',
'common.delete': '删除',
'common.yes': '是',
'common.no': '否',
'common.loading': '加载中...',
'common.ready': '就绪',
'common.autoSaved': '已自动保存',
'common.active': '活跃',
'common.idle': '空闲',
'common.general': '通用',
'common.state': '状态',
'common.tool': '工具',
'common.task': '任务',
'common.seat': '座位',
'common.role': '角色',
'common.employee': '员工',
'common.manager': '经理',
'common.origin': '来源',
'common.projection': '投影',
'common.gate': '关卡',
'common.runtime': '运行时',
'office.modeHint.org': '在“组织”页管理你的团队',
'office.modeHint.switch': '切换到“组织”模式以创建或管理 Agent',
'office.offices': '办公室',
'office.activeAgents': '活跃 Agent',
'office.rename': '重命名',
'office.noSeat': '未分配座位',
'office.moveHere': '+ 移到这里',
'office.characters': '角色',
'office.hideSub': '隐藏子项',
'office.showSub': '显示子项',
'office.hideSubagents': '隐藏子 Agent',
'office.showSubagents': '显示子 Agent',
'office.showSidePanel': '显示侧栏',
'office.hideSidePanel': '隐藏侧栏',
'office.removeAgent': '移除 {name}',
'office.deleteQuestion': '删除?',
'office.emptyAgents': '还没有 Agent,点击上方模板生成一个。',
'session.newChat': '新聊天',
'session.secretary': '秘书',
'session.search': '搜索...',
'session.activity': '动态',
'session.noSessions': '还没有会话',
'session.today': '今天',
'session.yesterday': '昨天',
'session.earlier': '更早',
'session.runtimeSuffix': '运行时会话',
'session.justNow': '刚刚',
'session.minutes': '{count} 分钟',
'session.hours': '{count} 小时',
'session.days': '{count} 天',
'session.companyRuntime': '公司运行时',
'session.expand': '展开',
'session.collapse': '折叠',
'session.subTask': '{count} 个子任务',
'session.subTasks': '{count} 个子任务',
'workspace.selectRuntimeSession': '在左侧选择一个运行时会话来查看它的工作项看板。',
'workspace.noWorkItems': '还没有工作项,开始委派后会填充此看板。',
'workspace.clickEditTitle': '点击编辑会话标题',
'workspace.channel.secretary': '秘书',
'workspace.channel.activity': '动态',
'workspace.confirmNoRoles': '你的组织还没有定义角色。\n\n运行任务前请至少设置一个角色。\n\n现在前往“组织”页吗?',
'workspace.confirmNoDecider': '你的组织有多个顶层角色,但尚未选择最终决策者。\n\n运行任务前请在“组织”页选择一个最终决策者。\n\n现在前往“组织”页吗?',
'workspace.warningNoTeams': '尚未定义运行时团队,系统会根据角色自动生成',
'workspace.warningVacantRoles': '{count} 个角色没有员工:{names}',
'workspace.confirmWarnings': '运行此任务前:\n\n{warnings}\n\n仍然运行吗?',
'kanban.addTask': '添加任务',
'kanban.taskTitle': '任务标题...',
'kanban.startTask': '启动任务',
'kanban.startWorkItem': '启动工作项',
'kanban.upstreamDeps': '{count} 个上游依赖',
'kanban.dep': '{count} 依赖',
'kanban.crossOffice': '跨办公室',
'kanban.executionTurn': '执行轮次:{id}',
'kanban.column.Todo': '待办',
'kanban.column.In Progress': '进行中',
'kanban.column.Done': '完成',
'kanban.column.Ready': '就绪',
'kanban.column.Running': '运行中',
'kanban.column.Review': '评审',
'kanban.column.Approved': '已批准',
'kanban.status.todo': '待办',
'kanban.status.in_progress': '进行中',
'kanban.status.in_review': '评审中',
'kanban.status.done': '完成',
'kanban.status.running': '运行中',
'kanban.status.idle': '空闲',
'kanban.status.blocked': '阻塞',
'kanban.status.awaiting_peer': '等待同伴',
'kanban.status.awaiting_manager_review': '经理评审',
'kanban.status.awaiting_human': '人工评审',
'kanban.status.awaiting_review': '评审中',
'kanban.status.failed': '失败',
'kanban.status.cancelled': '已取消',
'agent.status.idle': '空闲',
'agent.status.reflecting': '思考中...',
'agent.status.tool_active': '运行工具',
'agent.summary.active': '{active}/{total} 活跃',
'agent.summary.count': '{count} 个 Agent',
'agent.summary.countPlural': '{count} 个 Agent',
'org.loading': '正在加载组织数据...',
'org.company': '公司',
'org.customOrg': '自定义组织',
'org.corporateCompany': '企业公司',
'org.kind.saved': '已保存组织',
'org.kind.corporate': '企业',
'org.state.editableSaved': '可编辑的已保存架构',
'org.state.editableDraft': '可编辑草稿架构',
'org.state.readOnly': '内置只读架构',
'org.organization': '组织',
'org.corporate': '企业',
'org.newOrganization': '新建组织',
'org.roles': '角色',
'org.employees': '员工',
'org.runtimeTeams': '运行时团队',
'org.tab.team': '团队',
'org.tab.runtime': '运行时',
'org.tab.architecture': '架构',
'org.tab.employees': '员工',
'org.createdToast': '已创建 {name} 并自动保存',
'org.architectureApplied': '架构已成功应用',
}
const dictionaries = { en, 'zh-CN': zh } satisfies Record<Locale, Record<MessageKey, string>>
interface I18nContextValue {
locale: Locale
setLocale: (locale: Locale) => void
t: (key: MessageKey, params?: Record<string, string | number>) => string
translateMaybe: (namespace: string, value: string | undefined | null) => string
}
const I18nContext = createContext<I18nContextValue | null>(null)
function readInitialLocale(): Locale {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored === 'en' || stored === 'zh-CN') return stored
} catch {
// private mode
}
try {
return navigator.language.toLowerCase().startsWith('zh') ? 'zh-CN' : 'en'
} catch {
return 'en'
}
}
function interpolate(template: string, params?: Record<string, string | number>): string {
if (!params) return template
return template.replace(/\{(\w+)\}/g, (_, key: string) => (
Object.prototype.hasOwnProperty.call(params, key) ? String(params[key]) : `{${key}}`
))
}
export function I18nProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<Locale>(() => readInitialLocale())
useEffect(() => {
document.documentElement.lang = locale === 'zh-CN' ? 'zh-CN' : 'en'
}, [locale])
const setLocale = useCallback((next: Locale) => {
setLocaleState(next)
try {
localStorage.setItem(STORAGE_KEY, next)
document.documentElement.lang = next === 'zh-CN' ? 'zh-CN' : 'en'
} catch {
// private mode
}
}, [])
const t = useCallback<I18nContextValue['t']>((key, params) => {
const template = dictionaries[locale][key] ?? dictionaries.en[key] ?? key
return interpolate(template, params)
}, [locale])
const translateMaybe = useCallback<I18nContextValue['translateMaybe']>((namespace, value) => {
const normalized = String(value ?? '').trim()
if (!normalized) return ''
const key = `${namespace}.${normalized}` as MessageKey
return dictionaries[locale][key] ?? normalized
}, [locale])
const value = useMemo<I18nContextValue>(() => ({ locale, setLocale, t, translateMaybe }), [locale, setLocale, t, translateMaybe])
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>
}
export function useI18n(): I18nContextValue {
const ctx = useContext(I18nContext)
if (!ctx) throw new Error('useI18n must be used within I18nProvider')
return ctx
}
@@ -426,6 +426,41 @@ html, body, #root {
gap: 6px;
}
.language-toggle {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
height: 30px;
padding: 2px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
}
.language-toggle-btn {
flex: 0 0 auto;
min-width: 34px;
height: 24px;
padding: 0 8px;
border: 0;
border-radius: calc(var(--radius-sm) - 2px);
background: transparent;
color: var(--text-secondary);
font-size: 11px;
line-height: 1;
cursor: pointer;
}
.language-toggle-btn:hover {
color: var(--text);
background: var(--surface-hover);
}
.language-toggle-btn.active {
color: var(--accent);
background: var(--accent-soft);
}
.theme-select {
font-size: 11px;
padding: 4px 8px;
@@ -2,6 +2,7 @@ import { useMemo } from 'react'
import type { AgentInfo } from '../types/visual'
import type { AgentAnimStatus, KanbanTask } from '../types/kanban'
import { AGENT_STATUS_LABEL } from '../types/kanban'
import { useI18n } from '../i18n'
interface AgentStatusBarProps {
agents: AgentInfo[]
@@ -16,6 +17,7 @@ interface AgentState {
}
export function AgentStatusBar({ agents, tasks }: AgentStatusBarProps) {
const { t, translateMaybe } = useI18n()
const agentStates = useMemo<AgentState[]>(() => {
const tasksById = new Map(tasks.map((task) => [task.id, task]))
return agents.map(agent => {
@@ -42,15 +44,19 @@ export function AgentStatusBar({ agents, tasks }: AgentStatusBarProps) {
<div className="agent-status-bar">
<span className="agent-status-summary">
{activeCount > 0
? `${activeCount}/${agents.length} active`
: `${agents.length} agent${agents.length !== 1 ? 's' : ''}`}
? t('agent.summary.active', { active: activeCount, total: agents.length })
: t(agents.length !== 1 ? 'agent.summary.countPlural' : 'agent.summary.count', { count: agents.length })}
</span>
<div className="agent-status-chips">
{agentStates.map(({ agent, status, currentTool, taskDisplayId }) => (
{agentStates.map(({ agent, status, currentTool, taskDisplayId }) => {
const statusLabel = status === 'tool_active' && currentTool
? currentTool
: translateMaybe('agent.status', status) || AGENT_STATUS_LABEL[status]
return (
<div
key={agent.agent_id}
className={`agent-status-chip status-${status}`}
title={`${agent.name}: ${status === 'tool_active' && currentTool ? currentTool : AGENT_STATUS_LABEL[status]}${taskDisplayId ? ` (${taskDisplayId})` : ''}`}
title={`${agent.name}: ${statusLabel}${taskDisplayId ? ` (${taskDisplayId})` : ''}`}
>
<span className="agent-status-avatar">{agent.name.charAt(0).toUpperCase()}</span>
<span className="agent-status-name">{agent.name}</span>
@@ -58,7 +64,7 @@ export function AgentStatusBar({ agents, tasks }: AgentStatusBarProps) {
<>
<span className="kanban-runtime-dot" />
<span className="agent-status-detail">
{status === 'tool_active' && currentTool ? currentTool : AGENT_STATUS_LABEL[status]}
{statusLabel}
</span>
</>
)}
@@ -66,7 +72,8 @@ export function AgentStatusBar({ agents, tasks }: AgentStatusBarProps) {
<span className="agent-status-task">{taskDisplayId}</span>
)}
</div>
))}
)
})}
</div>
</div>
)
@@ -1,4 +1,5 @@
import type { KanbanBoard } from '../types/kanban'
import { useI18n } from '../i18n'
interface BoardSelectorProps {
boards: KanbanBoard[]
@@ -7,6 +8,7 @@ interface BoardSelectorProps {
}
export function BoardSelector({ boards, activeBoardId, onSelect }: BoardSelectorProps) {
const { translateMaybe } = useI18n()
return (
<div className="board-selector">
<div className="board-tabs">
@@ -18,7 +20,7 @@ export function BoardSelector({ boards, activeBoardId, onSelect }: BoardSelector
onClick={() => onSelect(b.id)}
>
<span className="board-tab-dot" style={{ background: b.color }} />
{b.name}
{translateMaybe('kanban.column', b.name) || b.name}
</button>
))}
</div>
@@ -3,6 +3,7 @@ import type { AgentInfo } from '../types/visual'
import { PRIORITY_META, AGENT_STATUS_LABEL, type KanbanTask } from '../types/kanban'
import { getWorkItemRoleLabel, humanizeWorkItemRoleId } from '../lib/workItemIdentity'
import { getLinkedRuntimeTaskId } from '../lib/workItemRuntimeIds'
import { useI18n } from '../i18n'
const STATUS_BADGE: Record<string, { label: string; color: string }> = {
todo: { label: 'To do', color: '#9ca3af' },
@@ -32,6 +33,7 @@ interface KanbanCardProps {
}
export function KanbanCard({ task, index, agents, officeMap, companyMode, isSelected, onClick, onStart }: KanbanCardProps) {
const { t, translateMaybe } = useI18n()
const assignees = task.assigneeIds
.map(id => agents.find(a => a.agent_id === id))
.filter(Boolean) as AgentInfo[]
@@ -54,6 +56,9 @@ export function KanbanCard({ task, index, agents, officeMap, companyMode, isSele
const blockerLabel = (task.blockedReason ?? '').trim()
const reworkLabel = (task.reworkFeedback ?? '').trim()
const linkedRuntimeTaskId = getLinkedRuntimeTaskId(task)
const runtimeStatusLabel = task.agentStatus
? (translateMaybe('agent.status', task.agentStatus) || AGENT_STATUS_LABEL[task.agentStatus] || task.agentStatus)
: ''
return (
<Draggable draggableId={task.id} index={index} isDragDisabled={!!companyMode}>
@@ -71,17 +76,17 @@ export function KanbanCard({ task, index, agents, officeMap, companyMode, isSele
{statusBadge && (
<span className="kanban-status-badge" style={{ color: statusBadge.color }}>
<span style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: statusBadge.color, marginRight: 3 }} />
{statusBadge.label}
{translateMaybe('kanban.status', phaseBadge) || statusBadge.label}
</span>
)}
{depCount > 0 && (
<span className="kanban-dep-badge" title={`${depCount} upstream dep(s)`}>{depCount} dep</span>
<span className="kanban-dep-badge" title={t('kanban.upstreamDeps', { count: depCount })}>{t('kanban.dep', { count: depCount })}</span>
)}
{crossOffice && <span className="kanban-cross-badge" title="Cross-office">&#x21C4;</span>}
{crossOffice && <span className="kanban-cross-badge" title={t('kanban.crossOffice')}>&#x21C4;</span>}
{onStart && (
<button
className="kanban-start-btn"
title={companyMode ? 'Start Work Item' : 'Start task'}
title={companyMode ? t('kanban.startWorkItem') : t('kanban.startTask')}
onClick={e => { e.stopPropagation(); onStart(task.id) }}
>
&#x25B6;
@@ -94,34 +99,34 @@ export function KanbanCard({ task, index, agents, officeMap, companyMode, isSele
{(roleLabel || employee?.name || gate?.type || task.originChannel || task.workItemProjectionId) && (
<div className="kanban-card-meta-row">
{roleLabel && (
<span className="kanban-meta-badge kanban-role-badge" title={`Role: ${roleLabel}`}>
<span className="kanban-meta-badge kanban-role-badge" title={`${t('common.role')}: ${roleLabel}`}>
{roleLabel}
</span>
)}
{employee?.name && (
<span className="kanban-meta-badge kanban-employee-badge" title={`Employee: ${employee.name}${employee.category ? ` (${employee.category})` : ''}`}>
<span className="kanban-meta-badge kanban-employee-badge" title={`${t('common.employee')}: ${employee.name}${employee.category ? ` (${employee.category})` : ''}`}>
<span className="kanban-meta-icon">&#x1F464;</span>
{employee.name}
</span>
)}
{task.workItemProjectionId && (
<span className="kanban-meta-badge kanban-projection-badge" title={`Projection: ${task.workItemProjectionId}`}>
<span className="kanban-meta-badge kanban-projection-badge" title={`${t('common.projection')}: ${task.workItemProjectionId}`}>
{task.workItemProjectionId}
</span>
)}
{gate?.type && (
<span className={`kanban-meta-badge kanban-gate-badge kanban-gate-${gate.type}`} title={`Gate: ${gate.type}${gate.reviewerRole ? ` by ${gate.reviewerRole}` : ''}`}>
<span className={`kanban-meta-badge kanban-gate-badge kanban-gate-${gate.type}`} title={`${t('common.gate')}: ${gate.type}${gate.reviewerRole ? ` by ${gate.reviewerRole}` : ''}`}>
{gate.type === 'review' ? '\u2709' : gate.type === 'approval' ? '\u2713' : '\u270B'}
{gate.type}
</span>
)}
{task.originChannel && (
<span className="kanban-meta-badge kanban-origin-badge" title={`Origin: ${task.originChannel}`}>
<span className="kanban-meta-badge kanban-origin-badge" title={`${t('common.origin')}: ${task.originChannel}`}>
#{task.originChannel}
</span>
)}
{managerLabel && (
<span className="kanban-meta-badge" title={`Manager: ${managerLabel}`}>
<span className="kanban-meta-badge" title={`${t('common.manager')}: ${managerLabel}`}>
{managerLabel}
</span>
)}
@@ -133,8 +138,8 @@ export function KanbanCard({ task, index, agents, officeMap, companyMode, isSele
{blockerLabel && <span className="kanban-tag">{blockerLabel}</span>}
{reworkLabel && <span className="kanban-tag">{reworkLabel}</span>}
{linkedRuntimeTaskId && (
<span className="kanban-tag" title={`Execution Turn: ${linkedRuntimeTaskId}`}>
Runtime
<span className="kanban-tag" title={t('kanban.executionTurn', { id: linkedRuntimeTaskId })}>
{t('common.runtime')}
</span>
)}
</div>
@@ -146,7 +151,7 @@ export function KanbanCard({ task, index, agents, officeMap, companyMode, isSele
<span className="kanban-runtime-label">
{task.agentStatus === 'tool_active' && task.currentTool
? task.currentTool
: AGENT_STATUS_LABEL[task.agentStatus!] ?? task.agentStatus}
: runtimeStatusLabel}
</span>
</div>
)}
@@ -3,6 +3,7 @@ import { Droppable } from '@hello-pangea/dnd'
import type { AgentInfo } from '../types/visual'
import type { KanbanColumn as KanbanColumnType, KanbanTask } from '../types/kanban'
import { KanbanCard } from './KanbanCard'
import { useI18n } from '../i18n'
interface KanbanColumnProps {
column: KanbanColumnType
@@ -17,6 +18,7 @@ interface KanbanColumnProps {
}
export function KanbanColumn({ column, tasks, agents, officeMap, companyMode, selectedTaskId, onCardClick, onStartTask, onQuickCreate }: KanbanColumnProps) {
const { t, translateMaybe } = useI18n()
const [adding, setAdding] = useState(false)
const [draft, setDraft] = useState('')
const committedRef = useRef(false)
@@ -36,10 +38,10 @@ export function KanbanColumn({ column, tasks, agents, officeMap, companyMode, se
<div className="kanban-column">
<div className="kanban-column-header">
<span className="kanban-col-dot" style={{ background: column.color }} />
<span className="kanban-col-label">{column.name}</span>
<span className="kanban-col-label">{translateMaybe('kanban.column', column.name) || column.name}</span>
<span className="kanban-col-count">{tasks.length}</span>
{onQuickCreate && (
<button className="kanban-col-add" title="Add task" onClick={() => { committedRef.current = false; setAdding(true) }}>+</button>
<button className="kanban-col-add" title={t('kanban.addTask')} onClick={() => { committedRef.current = false; setAdding(true) }}>+</button>
)}
</div>
@@ -54,7 +56,7 @@ export function KanbanColumn({ column, tasks, agents, officeMap, companyMode, se
if (e.key === 'Escape') { committedRef.current = true; setDraft(''); setAdding(false) }
}}
onBlur={commitAdd}
placeholder="Task title..."
placeholder={t('kanban.taskTitle')}
autoFocus
/>
</div>
@@ -1,6 +1,7 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import { I18nProvider } from './i18n'
import './index.css'
class ErrorBoundary extends React.Component<
@@ -66,7 +67,9 @@ if (!root) {
try {
createRoot(root).render(
<ErrorBoundary>
<I18nProvider>
<App />
</I18nProvider>
</ErrorBoundary>
)
} catch (err) {
@@ -18,6 +18,7 @@ import { EmployeesMarketplace } from './EmployeesMarketplace'
import { ConfigImportExportPanel } from './ConfigImportExportPanel'
import { OrgCreateModal } from './OrgCreateModal'
import { getRuntimeOrgView } from '../lib/runtimeOrg'
import { useI18n } from '../i18n'
import './org.css'
import './team.css'
import './marketplace.css'
@@ -122,6 +123,7 @@ export function OrgTab({
onSavedOrgsList, onSavedOrgSaveAs, onSavedOrgCreate, onSavedOrgLoad, onSavedOrgDelete, savedOrgsList,
activeSavedOrg, activeSavedOrgVersionAtLoad, orgCreatePending, orgCreateResult, onSelectCorporate,
}: OrgTabProps) {
const { t } = useI18n()
const [activeTab, setActiveTab] = useState<SubTab>('team')
const [createOpen, setCreateOpen] = useState(false)
@@ -166,8 +168,8 @@ export function OrgTab({
if (!orgCreateResult || !orgCreateResult.ok) return
setCreateOpen(false)
setActiveTab('team')
showToast(`Created ${orgCreateResult.organization_name || orgCreateResult.name} and saved automatically`)
}, [orgCreateResult, showToast])
showToast(t('org.createdToast', { name: orgCreateResult.organization_name || orgCreateResult.name }))
}, [orgCreateResult, showToast, t])
// In org mode: show only user-owned roles. In company mode: show all roles read-only.
const allRoles = data?.roles ?? []
@@ -176,13 +178,13 @@ export function OrgTab({
const runtimeView = useMemo(() => getRuntimeOrgView(data), [data])
const activeAgents = useMemo(() => displayEmployees.filter(e => e.linked_agent_id), [displayEmployees])
const configuredOrgName = data?.organization_name?.trim()
const activeOrgLabel = configuredOrgName || humanizeOrgName(activeSavedOrg) || (isCustomMode ? 'Custom org' : 'Corporate company')
const activeOrgLabel = configuredOrgName || humanizeOrgName(activeSavedOrg) || (isCustomMode ? t('org.customOrg') : t('org.corporateCompany'))
const activeOrgId = (isCustomMode ? (data?.organization_id || activeSavedOrg) : 'corporate') || ''
const architectureKindLabel = isCustomMode ? 'Saved org' : 'Corporate'
const architectureKindLabel = isCustomMode ? t('org.kind.saved') : t('org.kind.corporate')
const architectureStateLabel = isCustomMode
? activeSavedOrg ? 'Editable saved architecture' : 'Editable draft architecture'
: 'Built-in read-only architecture'
const runtimeStateLabel = runtimeView.frontier.status || runtimeView.projectRun?.status || runtimeView.projectRun?.lifecycle_status || 'ready'
? activeSavedOrg ? t('org.state.editableSaved') : t('org.state.editableDraft')
: t('org.state.readOnly')
const runtimeStateLabel = runtimeView.frontier.status || runtimeView.projectRun?.status || runtimeView.projectRun?.lifecycle_status || t('common.ready')
// Roles that already have at least one non-placeholder employee.
const filledRoleIds = useMemo(
@@ -210,9 +212,9 @@ export function OrgTab({
setApplyingPresetId(null)
versionAtApply.current = -1
setActiveTab('team')
showToast('Architecture applied successfully')
showToast(t('org.architectureApplied'))
}
}, [orgVersion, applyingPresetId, showToast])
}, [orgVersion, applyingPresetId, showToast, t])
const handleApplyPreset = (presetId: string, strategy: string) => {
versionAtApply.current = orgVersion // snapshot current version
@@ -232,7 +234,7 @@ export function OrgTab({
}
if (!data) {
return <div className="org-tab"><div className="org-loading">Loading organization data...</div></div>
return <div className="org-tab"><div className="org-loading">{t('org.loading')}</div></div>
}
const installedPackages = data.installed_packages ?? []
@@ -244,7 +246,7 @@ export function OrgTab({
<div className={`org-header${isCustomMode ? ' org-header--custom' : ' org-header--corporate'}`}>
<div className="org-header-main">
<div className="org-eyebrow">
<span>Company</span>
<span>{t('org.company')}</span>
<span className="org-eyebrow-separator">/</span>
<span>{architectureKindLabel}</span>
</div>
@@ -259,13 +261,13 @@ export function OrgTab({
<span className="org-meta-pill">{data.company_profile || (isCustomMode ? 'custom' : 'corporate')}</span>
{activeOrgId && <code className="org-meta-code">{activeOrgId}</code>}
<span className="org-meta-pill org-meta-pill--runtime">{runtimeStateLabel}</span>
<span className="org-meta-pill org-meta-pill--saved">Auto-saved</span>
<span className="org-meta-pill org-meta-pill--saved">{t('common.autoSaved')}</span>
</div>
</div>
<div className="org-control-panel">
<label className="org-switcher">
<span className="org-switcher-label">Organization</span>
<span className="org-switcher-label">{t('org.organization')}</span>
<span className="org-switcher-select-wrap">
<select
className="org-switcher-select"
@@ -273,9 +275,9 @@ export function OrgTab({
onChange={e => handleOrgSelection(e.target.value)}
onFocus={() => onSavedOrgsList?.()}
onPointerDown={() => onSavedOrgsList?.()}
aria-label="Organization"
aria-label={t('org.organization')}
>
<option value="corporate">Corporate</option>
<option value="corporate">{t('org.corporate')}</option>
{savedOrgOptions.map(org => (
<option key={org.name} value={`org:${org.name}`}>
{(org.organization_name || org.name).trim() || org.name}
@@ -286,36 +288,36 @@ export function OrgTab({
</label>
<button type="button" className="org-create-trigger" onClick={() => setCreateOpen(true)}>
<span className="org-create-trigger-icon" aria-hidden>+</span>
New organization
{t('org.newOrganization')}
</button>
</div>
<div className="org-stats-strip">
<span className="org-stat">
<img src={STAT_ICON.agents} alt="" className="org-stat-icon" />
<b>{displayRoles.length}</b> roles
<b>{displayRoles.length}</b> {t('org.roles')}
</span>
<span className="org-stat">
<img src={TAB_ICON.employees} alt="" className="org-stat-icon" />
<b>{displayEmployees.length}</b> employees
<b>{displayEmployees.length}</b> {t('org.employees')}
</span>
<span className="org-stat">
<img src={TAB_ICON.runtime} alt="" className="org-stat-icon" />
<b>{runtimeView.runtimeTeams.length}</b> runtime teams
<b>{runtimeView.runtimeTeams.length}</b> {t('org.runtimeTeams')}
</span>
<span className="org-stat">
<img src={STAT_ICON.active} alt="" className="org-stat-icon" />
<b>{activeAgents.length}</b> active
<b>{activeAgents.length}</b> {t('common.active')}
</span>
</div>
</div>
<div className="org-subtabs">
{([
{ id: 'team' as SubTab, icon: TAB_ICON.team, label: 'Team', count: displayRoles.length },
{ id: 'runtime' as SubTab, icon: TAB_ICON.runtime, label: 'Runtime', count: runtimeView.runtimeTeams.length },
{ id: 'architecture' as SubTab, icon: TAB_ICON.architecture, label: 'Architecture', count: marketPresets?.length ?? 0 },
{ id: 'employees' as SubTab, icon: TAB_ICON.employees, label: 'Employees', count: talents.length },
{ id: 'team' as SubTab, icon: TAB_ICON.team, label: t('org.tab.team'), count: displayRoles.length },
{ id: 'runtime' as SubTab, icon: TAB_ICON.runtime, label: t('org.tab.runtime'), count: runtimeView.runtimeTeams.length },
{ id: 'architecture' as SubTab, icon: TAB_ICON.architecture, label: t('org.tab.architecture'), count: marketPresets?.length ?? 0 },
{ id: 'employees' as SubTab, icon: TAB_ICON.employees, label: t('org.tab.employees'), count: talents.length },
]).map(tab => (
<button key={tab.id}
className={`org-subtab${activeTab === tab.id ? ' org-subtab--active' : ''}`}
@@ -22,6 +22,7 @@ import { getRuntimeOrgView } from '../lib/runtimeOrg'
import { getLinkedRuntimeTaskId } from '../lib/workItemRuntimeIds'
import { ContextPanel } from './ContextPanel'
import { useResizePanel } from './useResizePanel'
import { useI18n } from '../i18n'
type ActiveView =
| { kind: 'session'; taskId: string }
@@ -42,11 +43,25 @@ function makeOptimisticUserMessageId(): string {
/* ── Org mode pre-run readiness check ─────────────────────────────────── */
type TFunction = ReturnType<typeof useI18n>['t']
function checkOrgModeReadiness(
mode: string,
orgInfoData: OrgInfoPayload | null | undefined,
onNavigateToOrg?: () => void,
t?: TFunction,
): boolean {
const tr = t ?? ((key: string, params?: Record<string, string | number>) => {
const fallbacks: Record<string, string> = {
'workspace.confirmNoRoles': 'Your org has no roles defined.\n\nSet up at least one role before running a task.\n\nGo to Org tab now?',
'workspace.confirmNoDecider': 'Your organization has multiple top-level roles but no final decider selected.\n\nChoose one final decider in the Org tab before running a task.\n\nGo to Org tab now?',
'workspace.warningNoTeams': 'No runtime teams defined — the system will auto-generate from your roles',
'workspace.warningVacantRoles': '{count} role(s) have no employees: {names}',
'workspace.confirmWarnings': 'Before running this task:\n\n{warnings}\n\nRun anyway?',
}
const template = fallbacks[key] ?? key
return template.replace(/\{(\w+)\}/g, (_, name) => String(params?.[name] ?? `{${name}}`))
}) as TFunction
if (mode !== 'org' && mode !== 'custom') return true
if (!orgInfoData) return true
@@ -57,21 +72,13 @@ function checkOrgModeReadiness(
// Block: no roles defined
if (roles.length === 0) {
const goToOrg = confirm(
'Your org has no roles defined.\n\n' +
'Set up at least one role before running a task.\n\n' +
'Go to Org tab now?'
)
const goToOrg = confirm(tr('workspace.confirmNoRoles'))
if (goToOrg) onNavigateToOrg?.()
return false
}
if (topLevelRoleIds.length > 1 && !finalDeciderRoleId) {
const goToOrg = confirm(
'Your organization has multiple top-level roles but no final decider selected.\n\n' +
'Choose one final decider in the Org tab before running a task.\n\n' +
'Go to Org tab now?'
)
const goToOrg = confirm(tr('workspace.confirmNoDecider'))
if (goToOrg) onNavigateToOrg?.()
return false
}
@@ -84,7 +91,7 @@ function checkOrgModeReadiness(
|| (team.manager_role_id && roleIds.has(team.manager_role_id))
))
if (relevantTeams.length === 0) {
warnings.push('No runtime teams defined \u2014 the system will auto-generate from your roles')
warnings.push(tr('workspace.warningNoTeams'))
}
// Warn: roles without employees
@@ -92,15 +99,11 @@ function checkOrgModeReadiness(
const vacantRoles = roles.filter(r => !employeeRoleIds.has(r.role_id))
if (vacantRoles.length > 0) {
const names = vacantRoles.map(r => r.name).join(', ')
warnings.push(`${vacantRoles.length} role(s) have no employees: ${names}`)
warnings.push(tr('workspace.warningVacantRoles', { count: vacantRoles.length, names }))
}
if (warnings.length > 0) {
return confirm(
'Before running this task:\n\n' +
warnings.map(w => '\u2022 ' + w).join('\n') +
'\n\nRun anyway?'
)
return confirm(tr('workspace.confirmWarnings', { warnings: warnings.map(w => '\u2022 ' + w).join('\n') }))
}
return true
@@ -158,6 +161,7 @@ function BoardTitleEditor({
title: string
onCommit: (next: string) => void
}) {
const { t } = useI18n()
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(title)
const inputRef = useRef<HTMLInputElement>(null)
@@ -206,7 +210,7 @@ function BoardTitleEditor({
<span
className="board-tab-title"
onClick={startEditing}
title="Click to edit session title"
title={t('workspace.clickEditTitle')}
>
{title}
</span>
@@ -310,6 +314,7 @@ export function WorkspacePage({
onSavedOrgsList,
onSavedOrgLoad,
}: WorkspacePageProps) {
const { t } = useI18n()
const { sessions, activeSessionId, activeSession } = sessionStore
const { markRead } = chatStore
@@ -933,7 +938,7 @@ export function WorkspacePage({
: undefined
// Pre-run readiness check for org mode
if (!checkOrgModeReadiness(sessionExecMode, orgInfoData, onNavigateToOrg)) {
if (!checkOrgModeReadiness(sessionExecMode, orgInfoData, onNavigateToOrg, t)) {
return
}
@@ -948,7 +953,7 @@ export function WorkspacePage({
if (inProgressCol) {
boardStore.moveTask(taskId, inProgressCol.id, 0)
}
}, [boardStore, onRunTask, sessions, execMode, companyProfile, orgInfoData, onNavigateToOrg])
}, [boardStore, onRunTask, sessions, execMode, companyProfile, orgInfoData, onNavigateToOrg, t])
const handleSessionConfigChange = useCallback((taskId: string, sessionMode: string, sessionCompanyProfile?: string, orgId?: string) => {
onSessionConfigChange?.(taskId, sessionMode, sessionCompanyProfile, orgId)
@@ -1094,7 +1099,7 @@ export function WorkspacePage({
}, [handleOpenWorkItemSession, onOpenExecutionPanel])
const isSecretary = effectiveView.kind === 'secretary'
const channelName = isSecretary ? 'Secretary' : activeSession ? activeSession.title : 'Activity'
const channelName = isSecretary ? t('workspace.channel.secretary') : activeSession ? activeSession.title : t('workspace.channel.activity')
return (
<div className={`workspace-page${panelState === 'maximized' ? ' panel-maximized' : ''}`}>
@@ -1137,7 +1142,7 @@ export function WorkspacePage({
)}
{isCompanyMode && !boardStore.activeBoard ? (
<div className="kanban-empty-state">
<p>Select a Runtime Session on the left to view its Work Item board.</p>
<p>{t('workspace.selectRuntimeSession')}</p>
</div>
) : (
<>
@@ -1156,7 +1161,7 @@ export function WorkspacePage({
/>
{isCompanyMode && boardStore.activeBoard && boardStore.tasks.filter(t => t.boardId === boardStore.activeBoardId).length === 0 && (
<div className="kanban-empty-state kanban-empty-state-inline">
<p>No work items yet start delegation to populate this board.</p>
<p>{t('workspace.noWorkItems')}</p>
</div>
)}
</>
+47
View File
@@ -920,6 +920,53 @@ class CompanyCollaborationTests(unittest.IsolatedAsyncioTestCase):
any(item.startswith("[Workspace] Prepared workspace roots:") for item in task.metadata.get("progress_log", []))
)
async def test_failed_work_item_transitions_out_of_running_phase(self) -> None:
execute_task = AsyncMock(
return_value=TaskResult(status=TaskStatus.FAILED, content="provider limit")
)
save_task = AsyncMock()
executor = CompanyWorkItemExecutor(
org_engine=DummyOrgEngine(),
communication=SimpleNamespace(),
approval_engine=SimpleNamespace(),
memory=None,
execute_task=execute_task,
save_task=save_task,
)
task = Task(
id="failed-work-item-task",
title="Failed Work Item",
assigned_to="executor",
status=TaskStatus.PENDING,
metadata={
"work_item_projection_id": "failed_execution",
"work_item_role_id": "executor",
"work_item_turn_type": "execute",
"work_item_execution_strategy": "native",
"progress_log": [],
},
)
with patch(
"opc.layer2_organization.company_mode.transition_work_item_from_task",
new_callable=AsyncMock,
) as transition:
result = await executor._run_work_item(task, {task.id: task, "failed_execution": task})
self.assertEqual(result.status, TaskStatus.FAILED)
self.assertTrue(
any(
call.kwargs.get("target_status_or_phase") == TaskStatus.FAILED
and call.kwargs.get("reason") == "work_item_failed"
for call in transition.await_args_list
)
)
self.assertIn(
"Work item ended with status failed.",
task.metadata.get("progress_log", []),
)
save_task.assert_awaited()
async def test_workspace_bootstrap_work_item_records_manifest_and_reserved_layout(self) -> None:
with _workspace_tempdir() as tmpdir:
workspace = tmpdir / "runtime-root"
@@ -276,6 +276,154 @@ class CompanyRuntimeSuspendResumeTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(refreshed_item.metadata.get("dispatch_hold"), "")
self.assertEqual(refreshed_item.claimed_by_role_runtime_session_id, "")
async def test_continue_merges_task_suspended_after_checkpoint_creation(self) -> None:
store = await self._store()
plan, task = await self._seed_runtime(store)
engine = self._engine(store)
await engine.suspend_company_runtime(
origin_task_id=task.id,
session_id="sess-parent",
reason="startup_interrupted",
)
review_item = DelegationWorkItem(
work_item_id="review-item-v3",
run_id="run-1",
role_id="executor",
seat_id="seat-1",
title="Review v3",
summary="Review the completed report.",
kind="review",
projection_id="review-v3",
phase=Phase.RUNNING,
metadata={
"work_item_projection_id": "review-v3",
"dispatch_hold": "company_runtime_suspended",
"suspend_checkpoint_type": "company_runtime_interrupted",
"suspended_phase": Phase.RUNNING.value,
},
)
await store.save_delegation_work_item(review_item)
review_task = Task(
id="review-task-v3",
title="Review v3",
session_id="sess-review-v3",
parent_session_id="sess-parent",
status=TaskStatus.RUNNING,
project_id="proj1",
assigned_to="executor",
assigned_external_agent="codex",
metadata={
"company_profile": "corporate",
"execution_model": "multi_team_org",
"runtime_model": "multi_team_org",
"work_item_runtime": True,
"work_item_projection_id": "review-v3",
"delegation_run_id": "run-1",
"delegation_role_session_id": "role-runtime-1",
"company_work_item_plan": serialize_company_work_item_runtime_plan(plan),
"company_runtime_stop_state": "suspended",
"company_runtime_suspend_checkpoint_type": "company_runtime_interrupted",
"company_runtime_suspended_at": datetime.now().isoformat(),
"dispatch_hold": "company_runtime_suspended",
"suspended_task_status": TaskStatus.RUNNING.value,
},
)
set_linked_work_item_id(review_task, review_item.work_item_id)
await store.save_task(review_task)
await store.link_work_item_runtime_task(review_item.work_item_id, review_task.id)
captured: dict[str, Any] = {}
class DummyCompanyExecutor:
async def execute(
self,
resumed_plan: CompanyWorkItemRuntimePlan,
tasks: list[Task],
) -> str:
captured["plan"] = resumed_plan
captured["tasks"] = tasks
return "runtime resumed"
engine.company_executor = DummyCompanyExecutor()
await engine._maybe_resume_checkpoint(
"continue",
"sess-parent",
reply_metadata={"ui_force_resume": True},
)
resumed_ids = {item.id for item in captured["tasks"]}
refreshed_review_item = await store.get_delegation_work_item(
review_item.work_item_id
)
resolved = await store.get_execution_checkpoints(
project_id="proj1",
session_id="sess-parent",
checkpoint_types=["company_runtime_suspended"],
statuses=["resolved"],
)
self.assertEqual(resumed_ids, {task.id, review_task.id})
assert refreshed_review_item is not None
self.assertEqual(refreshed_review_item.metadata.get("dispatch_hold"), "")
self.assertEqual(len(resolved), 1)
self.assertEqual(
set(resolved[0].payload.get("task_ids", [])),
{task.id, review_task.id},
)
self.assertEqual(
{
snapshot.get("task_id")
for snapshot in resolved[0].payload.get("task_snapshots", [])
},
{task.id, review_task.id},
)
async def test_runtime_refreshes_canonical_adapter_state_before_persisting(self) -> None:
store = await self._store()
_, task = await self._seed_runtime(store)
role_session_id = "role-runtime-1"
stale_state = {
"selected_execution_agent": "codex",
"external_resume_session_id": "thread-terminal",
"external_resume_agent_type": "codex",
"codex": {
"resume_session_id": "thread-terminal",
"provider_session_id": "thread-terminal",
},
}
role_session = await store.get_delegation_role_session(role_session_id)
assert role_session is not None
role_session.adapter_session_state = dict(stale_state)
await store.save_delegation_role_session(role_session)
runtime = CompanyRuntime(org_engine=None, communication=None, store=store)
runtime.role_sessions[role_session_id] = role_session
member = CompanyMemberSession(
member_session_id="member-operations",
role_session_id=role_session_id,
role_id="executor",
employee_id="employee-1",
adapter_session_state=dict(stale_state),
)
await store.update_role_session_adapter_state(
role_session_id,
"codex",
None,
)
await runtime._refresh_adapter_session_state_from_store(member)
self.assertNotIn("codex", member.adapter_session_state)
self.assertNotIn(
"external_resume_session_id",
member.adapter_session_state,
)
self.assertEqual(
runtime.role_sessions[role_session_id].adapter_session_state,
member.adapter_session_state,
)
async def test_second_stop_during_resumed_execution_restores_pending_checkpoint(self) -> None:
store = await self._store()
_, task = await self._seed_runtime(store)
+72 -3
View File
@@ -2513,7 +2513,8 @@ class ExternalAgentMonitoringTests(unittest.IsolatedAsyncioTestCase):
def test_codex_adapter_resolves_launch_binary_before_spawn(self) -> None:
metadata: dict[str, object] = {}
with patch("opc.layer3_agent.adapters.base.shutil.which", return_value=r"C:\Tools\codex.cmd"):
with patch("opc.layer3_agent.adapters.base.os.name", "posix"), \
patch("opc.layer3_agent.adapters.base.shutil.which", return_value=r"C:\Tools\codex.cmd"):
cmd = CodexAdapter._resolve_launch_command(
["codex", "exec", "--json", "-"],
launch_metadata=metadata,
@@ -2523,6 +2524,39 @@ class ExternalAgentMonitoringTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(metadata["configured_binary"], "codex")
self.assertEqual(metadata["resolved_binary"], r"C:\Tools\codex.cmd")
def test_codex_adapter_wraps_windows_cmd_shim_before_spawn(self) -> None:
metadata: dict[str, object] = {}
which_results = {
"codex": r"D:\nodejs\codex",
"cmd.exe": r"C:\Windows\System32\cmd.exe",
}
with patch("opc.layer3_agent.adapters.base.os.name", "nt"), \
patch("opc.layer3_agent.adapters.base.os.path.isfile", side_effect=lambda path: path == r"D:\nodejs\codex.cmd"), \
patch("opc.layer3_agent.adapters.base.shutil.which", side_effect=lambda name, path=None: which_results.get(name)), \
patch.dict(os.environ, {"COMSPEC": r"C:\Windows\System32\cmd.exe"}, clear=False):
cmd = CodexAdapter._resolve_launch_command(
["codex", "exec", "--json", "-"],
launch_metadata=metadata,
)
self.assertEqual(
cmd,
[
r"C:\Windows\System32\cmd.exe",
"/d",
"/s",
"/c",
r"D:\nodejs\codex.cmd",
"exec",
"--json",
"-",
],
)
self.assertEqual(metadata["configured_binary"], "codex")
self.assertEqual(metadata["resolved_binary"], r"D:\nodejs\codex.cmd")
self.assertEqual(metadata["launch_wrapper"], r"C:\Windows\System32\cmd.exe")
def test_codex_adapter_resolves_launch_binary_with_extra_env_path(self) -> None:
with patch("opc.layer3_agent.adapters.base.shutil.which", return_value=r"D:\bin\codex.cmd") as which_mock:
CodexAdapter._resolve_launch_command(
@@ -2532,6 +2566,40 @@ class ExternalAgentMonitoringTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(which_mock.call_args.kwargs["path"], r"D:\bin")
def test_codex_adapter_forwards_only_openopc_runtime_env_to_shell_policy(self) -> None:
metadata: dict[str, object] = {}
cmd = CodexAdapter._inject_runtime_shell_environment(
["codex", "exec", "--json", "-"],
{
"OPC_ALLOWED_COLLAB_TOOLS": json.dumps(["delegate_work", "inbox"]),
"OPC_COLLAB_RPC_TOKEN": "rpc-token",
"PYTHONPATH": r"D:\OpenOPC",
"UNRELATED_SECRET": "do-not-forward",
},
launch_metadata=metadata,
)
self.assertEqual(cmd[:2], ["codex", "exec"])
config_values = [cmd[index + 1] for index, value in enumerate(cmd[:-1]) if value == "-c"]
self.assertIn(
"shell_environment_policy.set.OPC_ALLOWED_COLLAB_TOOLS='''delegate_work,inbox'''",
config_values,
)
self.assertIn(
"shell_environment_policy.set.OPC_COLLAB_RPC_TOKEN='''rpc-token'''",
config_values,
)
self.assertIn(
"shell_environment_policy.set.PYTHONPATH='''D:\\OpenOPC'''",
config_values,
)
self.assertFalse(any("UNRELATED_SECRET" in value for value in config_values))
self.assertEqual(
metadata["runtime_shell_environment_keys"],
["OPC_ALLOWED_COLLAB_TOOLS", "OPC_COLLAB_RPC_TOKEN", "PYTHONPATH"],
)
def test_codex_adapter_full_auto_uses_dangerous_bypass_flag(self) -> None:
adapter = CodexAdapter(
config=ExternalAgentConfig(command="codex", approval_mode="full-auto")
@@ -5242,10 +5310,11 @@ class ExternalAgentMonitoringTests(unittest.IsolatedAsyncioTestCase):
with tempfile.TemporaryDirectory() as tmpdir:
store = _SessionStoreStub()
adapter = _CollabSurfaceScriptAdapter("print('company-mode external run')\n")
broker = ExternalAgentBroker(store, _ApprovalStub())
from opc.layer2_organization.org_engine import OrgEngine
from opc.core.config import OPCConfig
broker.org_engine = OrgEngine(OPCConfig(), Path(tmpdir))
org_engine = OrgEngine(OPCConfig(), Path(tmpdir))
broker = ExternalAgentBroker(store, _ApprovalStub(), org_engine=org_engine)
self.assertIs(broker.org_engine, org_engine)
task = Task(
id="collab-env-ceo",
title="collab env ceo",
+141
View File
@@ -21,6 +21,7 @@ from __future__ import annotations
import tempfile
import unittest
from datetime import datetime, timedelta
from pathlib import Path
from types import SimpleNamespace
@@ -37,6 +38,7 @@ from opc.core.models import (
from opc.database.store import OPCStore
from opc.layer3_agent.adapters.base import ExternalAgentAdapter
from opc.layer3_agent.external_broker import ExternalAgentBroker
from opc.layer3_agent.external_session_identity import select_best_external_resume_session
# ── Store helper unit tests ──────────────────────────────────────────────
@@ -97,9 +99,28 @@ class AdapterSessionStateHelperTests(unittest.IsolatedAsyncioTestCase):
await self.store.update_role_session_adapter_state(
self.sid, "codex", {"resume_session_id": "thread-abc"},
)
role_session = await self.store.get_delegation_role_session(self.sid)
role_session.adapter_session_state.update({
"selected_execution_agent": "codex",
"external_resume_session_id": "thread-abc",
"external_resume_session_scope_id": "sess-a",
"external_resume_agent_type": "codex",
"resume_source_session": "thread-abc",
})
await self.store.save_delegation_role_session(role_session)
await self.store.update_role_session_adapter_state(self.sid, "codex", None)
entry = await self.store.get_role_session_adapter_state(self.sid, "codex")
refreshed = await self.store.get_delegation_role_session(self.sid)
self.assertIsNone(entry)
self.assertEqual(
refreshed.adapter_session_state.get("selected_execution_agent"),
"codex",
)
self.assertNotIn(
"external_resume_session_id",
refreshed.adapter_session_state,
)
self.assertNotIn("resume_source_session", refreshed.adapter_session_state)
async def test_missing_session_returns_false(self) -> None:
ok = await self.store.update_role_session_adapter_state(
@@ -245,6 +266,49 @@ class BrokerPersistWritesRoleStateTests(unittest.IsolatedAsyncioTestCase):
)
self.assertIsNone(entry)
async def test_failed_resume_clears_matching_older_role_token_and_task_metadata(self) -> None:
await self._persist_run(
task_id="task-old",
agent_type="codex",
resume_session_id="thread-full",
)
adapter = _MiniAdapter(agent_type="codex")
adapter.config.session_mode = "resume"
adapter.config.session_id = "thread-full"
task = self._task(task_id="task-retry")
task.metadata.update({
"external_resume_session_id": "thread-full",
"external_resume_session_scope_id": "sess-a",
"external_resume_agent_type": "codex",
})
await self.broker._persist_session(
adapter=adapter,
task=task,
workspace_path="/tmp/ws",
metadata={
"command": "codex exec resume",
"model": "(cli default)",
"resume_session_id": "thread-full",
},
result=TaskResult(
status=TaskStatus.FAILED,
content="context window full",
artifacts={"resume_session_id": "thread-full"},
),
)
self.assertIsNone(
await self.store.get_role_session_adapter_state(
self.role_session_id,
"codex",
)
)
self.assertNotIn("external_resume_session_id", task.metadata)
self.assertEqual(
task.metadata.get("external_resume_fallback"),
"provider_terminal_failure",
)
async def test_consecutive_tasks_overwrite_with_latest_token(self) -> None:
await self._persist_run(
task_id="task-1", agent_type="codex", resume_session_id="thread-1",
@@ -258,6 +322,34 @@ class BrokerPersistWritesRoleStateTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(entry["resume_session_id"], "thread-2")
self.assertEqual(entry["last_task_id"], "task-2")
def test_newer_failed_row_vetoes_older_done_row_for_same_token(self) -> None:
now = datetime.now()
older = ExternalSession(
agent_type="codex",
project_id="proj1",
session_id="thread-full",
status="done",
metadata={"resume_session_id": "thread-full"},
updated_at=now - timedelta(minutes=1),
)
newer = ExternalSession(
agent_type="codex",
project_id="proj1",
session_id="thread-full",
status="failed",
metadata={"resume_session_id": "thread-full"},
updated_at=now,
)
selected, token = select_best_external_resume_session(
[older, newer],
agent_type="codex",
project_id="proj1",
)
self.assertIsNone(selected)
self.assertEqual(token, "")
async def test_per_agent_isolation_codex_claude_opencode(self) -> None:
# All three external adapters land in the same broker path. Each
# gets its own slot under adapter_session_state keyed by agent_type.
@@ -430,6 +522,55 @@ class BrokerRestorePrefersRoleStateTests(unittest.IsolatedAsyncioTestCase):
"codex",
))
async def test_restore_rejects_terminal_normal_role_token_even_when_preconfigured(self) -> None:
token = "thread-terminal"
await self.store.update_role_session_adapter_state(
self.role_session_id,
"codex",
{
"resume_session_id": token,
"provider_session_id": token,
"last_task_id": "task-old",
},
)
await self.store.save_external_session(ExternalSession(
agent_type="codex",
project_id="proj1",
session_id=token,
opc_session_id=self.role_session_id,
task_id="task-failed",
workspace_path="/tmp/ws",
run_mode="exec",
status="failed",
metadata={
"resume_session_id": token,
"provider_session_id": token,
},
))
adapter = _MiniAdapter(agent_type="codex", can_resume_blank=False)
adapter.config.session_mode = "resume"
adapter.config.session_id = token
task = self._task()
task.metadata.update({
"external_resume_session_id": token,
"external_resume_session_scope_id": "sess-a",
"external_resume_agent_type": "codex",
})
await self.broker._restore_session_resume_from_store(adapter, task)
self.assertEqual(adapter.config.session_mode, "new")
self.assertEqual(adapter.config.session_id, "")
self.assertIsNone(await self.store.get_role_session_adapter_state(
self.role_session_id,
"codex",
))
self.assertNotIn("external_resume_session_id", task.metadata)
self.assertEqual(
task.metadata.get("external_resume_fallback"),
"provider_terminal_failure",
)
if __name__ == "__main__":
unittest.main()
+26
View File
@@ -164,6 +164,32 @@ def _make_review_task(
class VerdictParseRetryTests(unittest.IsolatedAsyncioTestCase):
async def test_terminal_failed_review_card_can_record_parse_failure_outcome(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
store = OPCStore(root / "tasks.db")
await store.initialize()
try:
executor = _build_executor(store, _make_org_engine(root))
review = _make_review_card(review_card_id="review-failed")
review.phase = Phase.FAILED
await store.save_delegation_work_item(review)
persisted = await executor._persist_terminal_review_card(
"review-failed",
phase=Phase.CANCELLED,
outcome="verdict_parse_failed",
)
self.assertIsNotNone(persisted)
self.assertEqual(persisted.phase, Phase.FAILED)
self.assertEqual(
persisted.metadata.get("review_work_item_outcome"),
"verdict_parse_failed",
)
finally:
await store.close()
async def test_unparseable_verdict_spawns_retry_with_hint(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)