Merge PR #9: harden company resume, external sessions, and Office UI i18n

Merges xiaodong-l's hardening work: company suspend-checkpoint resume
reconciliation, external session token lifecycle fixes, Windows launch
shims, codex shell_environment_policy forwarding, Office UI i18n
(en/zh-CN), and two org presets. frontend_dist is rebuilt from the
merged sources in this commit so the #11 runtime-status fixes and the
i18n bundle coexist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

# Conflicts:
#	opc/plugins/office_ui/frontend_dist/assets/index-BgyI65M_.js
#	opc/plugins/office_ui/frontend_dist/index.html
This commit is contained in:
LZH-YS1998
2026-07-21 15:34:28 +08:00
32 changed files with 2562 additions and 445 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 = {} current = {}
if token_record is None: if token_record is None:
current.pop(agent, 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: else:
current[agent] = { current[agent] = {
str(k): v for k, v in dict(token_record).items() str(k): v for k, v in dict(token_record).items()
+92 -4
View File
@@ -665,6 +665,7 @@ class OPCEngine:
self.approval_engine, self.approval_engine,
task_preparer=self._build_external_agent_task, task_preparer=self._build_external_agent_task,
communication=self.communication, communication=self.communication,
org_engine=self.org_engine,
) )
self.secretary = SecretaryService( self.secretary = SecretaryService(
llm=self.llm, llm=self.llm,
@@ -11330,6 +11331,53 @@ class OPCEngine:
if callable(reset): if callable(reset):
await reset(tasks, payload=payload) 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( async def _load_company_suspend_checkpoint_runtime(
self, self,
checkpoint: ExecutionCheckpoint, checkpoint: ExecutionCheckpoint,
@@ -11352,16 +11400,56 @@ class OPCEngine:
task = await self.store.get_task(task_id) task = await self.store.get_task(task_id)
if task: if task:
tasks.append(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) snapshot = await self._load_company_runtime_snapshot(parent_session_id)
if snapshot: 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: if not tasks:
await self.store.resolve_execution_checkpoint(checkpoint.checkpoint_id, status="invalid") await self.store.resolve_execution_checkpoint(checkpoint.checkpoint_id, status="invalid")
return None 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_id"] = checkpoint.checkpoint_id
payload["checkpoint_type"] = checkpoint.checkpoint_type payload["checkpoint_type"] = checkpoint.checkpoint_type
return payload, parent_session_id, plan, tasks return payload, parent_session_id, plan, tasks
+22 -4
View File
@@ -5661,7 +5661,18 @@ class CompanyWorkItemExecutor:
await self._emit_progress(f"[Company:{projection_id}] awaiting peer", task_id=task.id) await self._emit_progress(f"[Company:{projection_id}] awaiting peer", task_id=task.id)
await self.save_task(task) await self.save_task(task)
else: 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._emit_progress(f"[Company:{projection_id}] failed", task_id=task.id)
await self.save_task(task)
return result return result
# Comms park check: if the agent sent any blocking messages # Comms park check: if the agent sent any blocking messages
@@ -7532,12 +7543,19 @@ class CompanyWorkItemExecutor:
metadata_updates["review_resolution"] = copy.deepcopy(resolution) metadata_updates["review_resolution"] = copy.deepcopy(resolution)
metadata_updates["review_resolution_state"] = "pending" metadata_updates["review_resolution_state"] = "pending"
try: 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( persisted = await self.store.update_delegation_work_item(
review_work_item_id, review_work_item_id,
phase=phase, **update_kwargs,
claimed_by_role_runtime_session_id="",
claimed_by_seat_id="",
metadata_updates=metadata_updates,
) )
except Exception: except Exception:
logger.opt(exception=True).warning( logger.opt(exception=True).warning(
@@ -1710,6 +1710,7 @@ class CompanyRuntime:
**dict(session.resume_state), **dict(session.resume_state),
**runtime_state, **runtime_state,
} }
await self._refresh_adapter_session_state_from_store(session)
adapter_state_updates = self._adapter_state_from_result(task, result) adapter_state_updates = self._adapter_state_from_result(task, result)
if adapter_state_updates: if adapter_state_updates:
session.adapter_session_state = { session.adapter_session_state = {
@@ -1899,6 +1900,31 @@ class CompanyRuntime:
return candidate return candidate
return None 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: def _ensure_role_session(self, task: Task) -> DelegationRoleSession | None:
role_id = self._role_id(task) role_id = self._role_id(task)
if not role_id: 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()}} 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 "" path_value = merged.get("PATH") or merged.get("Path") or merged.get("path") or ""
resolved = shutil.which(executable, path=path_value or None) 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 return cmd
resolved_cmd = list(cmd) resolved_cmd = list(cmd)
resolved_cmd[0] = resolved resolved_cmd[0] = resolved
@@ -526,6 +547,31 @@ class ExternalAgentAdapter(abc.ABC):
launch_metadata["resolved_binary"] = resolved launch_metadata["resolved_binary"] = resolved
return resolved_cmd 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( async def send_process_input(
self, self,
proc: asyncio.subprocess.Process, proc: asyncio.subprocess.Process,
+66 -1
View File
@@ -32,6 +32,8 @@ class CodexAdapter(ExternalAgentAdapter):
"CODEX_SANDBOX_NETWORK_DISABLED", "CODEX_SANDBOX_NETWORK_DISABLED",
"CODEX_THREAD_ID", "CODEX_THREAD_ID",
} }
_RUNTIME_SHELL_ENV_KEYS = {"PYTHONPATH"}
_RUNTIME_SHELL_ENV_PREFIXES = ("OPC_",)
_PROMPT_SENTINEL = "-" _PROMPT_SENTINEL = "-"
_TTY_EOF = b"\x04" _TTY_EOF = b"\x04"
_INTERACTIVE_ARGV_PROMPT_MAX_BYTES = 16 * 1024 _INTERACTIVE_ARGV_PROMPT_MAX_BYTES = 16 * 1024
@@ -118,6 +120,64 @@ class CodexAdapter(ExternalAgentAdapter):
normalized = key.upper() normalized = key.upper()
return normalized in cls._PARENT_CODEX_RUNTIME_ENV_VARS 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]: def build_workspace_args(self, workspace_path: str | None = None) -> list[str]:
args: list[str] = [] args: list[str] = []
if workspace_path: if workspace_path:
@@ -564,8 +624,13 @@ class CodexAdapter(ExternalAgentAdapter):
prompt_transport = str((launch_metadata or {}).get("prompt_transport") or "").strip().lower() prompt_transport = str((launch_metadata or {}).get("prompt_transport") or "").strip().lower()
prompt = self._prompt_text_from_task(task) if prompt_transport == "stdin" else "" prompt = self._prompt_text_from_task(task) if prompt_transport == "stdin" else ""
env = self.build_process_env(extra_env) env = self.build_process_env(extra_env)
launch_cmd = self._resolve_launch_command( runtime_cmd = self._inject_runtime_shell_environment(
cmd, cmd,
extra_env,
launch_metadata=launch_metadata,
)
launch_cmd = self._resolve_launch_command(
runtime_cmd,
extra_env=extra_env, extra_env=extra_env,
launch_metadata=launch_metadata, launch_metadata=launch_metadata,
) )
+95 -33
View File
@@ -92,11 +92,13 @@ class ExternalAgentBroker:
approval_engine: ApprovalEngine, approval_engine: ApprovalEngine,
task_preparer: Callable[[Task], Coroutine[Any, Any, Task]] | None = None, task_preparer: Callable[[Task], Coroutine[Any, Any, Task]] | None = None,
communication: CommunicationManager | None = None, communication: CommunicationManager | None = None,
org_engine: Any | None = None,
) -> None: ) -> None:
self.store = store self.store = store
self.approval_engine = approval_engine self.approval_engine = approval_engine
self.task_preparer = task_preparer self.task_preparer = task_preparer
self.communication = communication self.communication = communication
self.org_engine = org_engine
@staticmethod @staticmethod
def _normalize_external_agent_choice(value: Any) -> str: def _normalize_external_agent_choice(value: Any) -> str:
@@ -137,19 +139,19 @@ class ExternalAgentBroker:
) )
return selected return selected
async def _provider_stream_token_allows_resume( async def _stored_provider_token_allows_resume(
self, self,
*, *,
adapter: ExternalAgentAdapter, adapter: ExternalAgentAdapter,
task: Task, task: Task,
role_session_id: str, role_session_id: str,
token: str, token: str,
) -> bool: ) -> bool | None:
"""Cross-check an early stream token against its latest run status.""" """Return the latest durable resumability verdict for one provider token."""
list_sessions = getattr(self.store, "list_external_sessions", None) list_sessions = getattr(self.store, "list_external_sessions", None)
if not callable(list_sessions): if not callable(list_sessions):
return False return None
project_id = str(task.project_id or "default").strip() or "default" project_id = str(task.project_id or "default").strip() or "default"
kwargs: dict[str, Any] = {"project_id": project_id, "limit": 100} kwargs: dict[str, Any] = {"project_id": project_id, "limit": 100}
if role_session_id: if role_session_id:
@@ -160,19 +162,30 @@ class ExternalAgentBroker:
sessions = await list_sessions(**kwargs) sessions = await list_sessions(**kwargs)
except Exception: except Exception:
logger.opt(exception=True).debug( logger.opt(exception=True).debug(
"External resume restore: provider-stream status lookup failed" "External resume restore: provider-token status lookup failed"
) )
return False return None
for session in sessions: matching = [
if str(getattr(session, "agent_type", "") or "").strip() != adapter.agent_type: session
continue for session in sessions
if external_session_matches_provider_token(session, token): if str(getattr(session, "agent_type", "") or "").strip()
return ( == adapter.agent_type
external_session_allows_resume(session) and external_session_matches_provider_token(session, token)
and str(getattr(session, "status", "") or "").strip().lower() ]
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"} in {"done", "suspended"}
) )
return False
@classmethod @classmethod
def _task_explicitly_selected_external_agent(cls, task: Task, agent_type: str) -> bool: def _task_explicitly_selected_external_agent(cls, task: Task, agent_type: str) -> bool:
@@ -474,9 +487,12 @@ class ExternalAgentBroker:
return return
if str(getattr(config, "session_mode", "") or "").strip().lower() == "new": if str(getattr(config, "session_mode", "") or "").strip().lower() == "new":
return return
# Respect already-configured resume state. configured_session_token = (
if str(getattr(config, "session_mode", "") or "").strip().lower() == "resume" and getattr(config, "session_id", ""): str(getattr(config, "session_id", "") or "").strip()
return if str(getattr(config, "session_mode", "") or "").strip().lower()
== "resume"
else ""
)
store = getattr(self, "store", None) store = getattr(self, "store", None)
if store is None or not hasattr(store, "get_external_session"): 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]``. # Fix 5 PR6: canonical source is ``role_runtime_session.adapter_session_state[agent_type]``.
# Check it first; the ExternalSession table is a compatibility # Check it first; the ExternalSession table is a compatibility
# fallback for legacy rows written before PR6 landed. # 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"): if role_session_id and hasattr(store, "get_role_session_adapter_state"):
try: try:
entry = await store.get_role_session_adapter_state( entry = await store.get_role_session_adapter_state(
@@ -507,28 +523,29 @@ class ExternalAgentBroker:
) )
entry = None entry = None
if isinstance(entry, dict): if isinstance(entry, dict):
session_token = str( role_session_token = str(
entry.get("resume_session_id") entry.get("resume_session_id")
or entry.get("provider_session_id") or entry.get("provider_session_id")
or "" or ""
).strip() ).strip()
if not is_provider_session_token( if not is_provider_session_token(
session_token, role_session_token,
agent_type=adapter.agent_type, agent_type=adapter.agent_type,
project_id=project_id, project_id=project_id,
): ):
session_token = "" role_session_token = ""
if ( session_token = session_token or role_session_token
session_token token_allows_resume = (
and str(entry.get("source", "") or "").strip() await self._stored_provider_token_allows_resume(
== "provider_stream"
and not await self._provider_stream_token_allows_resume(
adapter=adapter, adapter=adapter,
task=task, task=task,
role_session_id=role_session_id, role_session_id=role_session_id,
token=session_token, token=session_token,
) )
): if session_token
else None
)
if session_token and token_allows_resume is False:
session_token = "" session_token = ""
clear_role_state = getattr( clear_role_state = getattr(
store, store,
@@ -544,7 +561,22 @@ class ExternalAgentBroker:
) )
except Exception: except Exception:
logger.opt(exception=True).debug( 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 # Do not immediately rediscover the same unfinalized or
# failed stream row through the compatibility fallback. # failed stream row through the compatibility fallback.
@@ -2553,19 +2585,33 @@ class ExternalAgentBroker:
and hasattr(self.store, "update_role_session_adapter_state") and hasattr(self.store, "update_role_session_adapter_state")
): ):
# A stream token is durable early so Stop can retain it. A normal # A stream token is durable early so Stop can retain it. A normal
# terminal failure, however, must not leave that attempt's token # terminal failure must clear both a token discovered by this task
# pinned for a later unrelated turn. # and an older role token that this failed attempt resumed.
try: try:
current = await self.store.get_role_session_adapter_state( current = await self.store.get_role_session_adapter_state(
role_session_id, role_session_id,
adapter.agent_type, 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 ( if (
isinstance(current, dict) 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() == str(task.id or "").strip()
and str(current.get("source", "") or "").strip() or bool(failed_token and current_token == failed_token)
== "provider_stream" )
): ):
await self.store.update_role_session_adapter_state( await self.store.update_role_session_adapter_state(
role_session_id, role_session_id,
@@ -2576,3 +2622,19 @@ class ExternalAgentBroker:
logger.opt(exception=True).debug( logger.opt(exception=True).debug(
"Failed to clear provider-stream role state after terminal failure" "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: ) -> str:
if not external_session_allows_resume(session): if not external_session_allows_resume(session):
return "" 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 {}) metadata = dict(getattr(session, "metadata", {}) or {})
for candidate in ( for candidate in (
metadata.get("resume_session_id"), metadata.get("resume_session_id"),
@@ -108,7 +125,7 @@ def select_best_external_resume_session(
) -> tuple[Any | None, str]: ) -> tuple[Any | None, str]:
"""Select the newest valid provider capability, ignoring placeholders.""" """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() normalized_agent = str(agent_type or "").strip()
for session in list(sessions or []): for session in list(sessions or []):
if ( if (
@@ -116,23 +133,35 @@ def select_best_external_resume_session(
!= normalized_agent != normalized_agent
): ):
continue continue
token = provider_token_from_external_session( token = _provider_token_from_session_identity(
session, session,
agent_type=normalized_agent, agent_type=normalized_agent,
project_id=project_id, project_id=project_id,
) )
if token: if not token:
valid.append((session, 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: if not valid:
return None, "" return None, ""
def _sort_key(item: tuple[Any, str]) -> tuple[float, str]: def _sort_key(item: tuple[Any, str]) -> tuple[float, str]:
session, token = item session, token = item
updated_at = getattr(session, "updated_at", None) return _session_timestamp(session), token
try:
timestamp = float(updated_at.timestamp())
except Exception:
timestamp = 0.0
return timestamp, token
return max(valid, key=_sort_key) 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) _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: def _ensure_frontend() -> None:
"""Build frontend if frontend_dist is missing, empty, or stale.""" """Build frontend if frontend_dist is missing, empty, or stale."""
if not _frontend_needs_rebuild(): if not _frontend_needs_rebuild():
@@ -114,11 +127,11 @@ def _ensure_frontend() -> None:
node_modules = _FRONTEND_SRC / "node_modules" node_modules = _FRONTEND_SRC / "node_modules"
if not node_modules.is_dir(): if not node_modules.is_dir():
terminal_status("Installing npm dependencies") 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 # npm run build → outputs to ../frontend_dist via vite config
terminal_status("Building frontend bundle") 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()): if _FRONTEND_DIST.is_dir() and any(_FRONTEND_DIST.iterdir()):
terminal_status("Frontend built successfully", kind="success") 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,9 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:," /> <link rel="icon" href="data:," />
<title>OpenOPC Pixel Office</title> <title>OpenOPC Pixel Office</title>
<script type="module" crossorigin src="./assets/index-BgyI65M_.js"></script> <script type="module" crossorigin src="./assets/index-Dw3OK9za.js"></script>
<link rel="modulepreload" crossorigin href="./assets/phaser-DFK5Ua9d.js"> <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> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+71 -47
View File
@@ -27,6 +27,7 @@ import { extractSessionRecruitmentByRole, sessionChannelId } from './lib/session
import { resolveCanonicalTurnId, terminalAssistantTurnId } from './lib/turnIdentity' import { resolveCanonicalTurnId, terminalAssistantTurnId } from './lib/turnIdentity'
import { unassignAgent } from './game/map/OfficeStore' import { unassignAgent } from './game/map/OfficeStore'
import type { AgentAnimStatus, EmployeeAssignment, KanbanPhase, KanbanTask, RoleAggregatedStatus, RoleWorkItemSummary, Session, TaskPreferredAgent } from './types/kanban' import type { AgentAnimStatus, EmployeeAssignment, KanbanPhase, KanbanTask, RoleAggregatedStatus, RoleWorkItemSummary, Session, TaskPreferredAgent } from './types/kanban'
import { useI18n } from './i18n'
function readOutdoorOverrideUi(): 'auto' | 'day' | 'night' { function readOutdoorOverrideUi(): 'auto' | 'day' | 'night' {
try { try {
@@ -444,6 +445,7 @@ function MaybeExecutionPanel({ taskId, sessions, agents, onClose }: {
} }
export default function App() { export default function App() {
const { locale, setLocale, t, translateMaybe } = useI18n()
const bridgeRef = useRef(new GameBridge()) const bridgeRef = useRef(new GameBridge())
useMemo(() => registerTestRunner(bridgeRef.current), []) useMemo(() => registerTestRunner(bridgeRef.current), [])
const clientRef = useRef<VisualSocketClient | null>(null) const clientRef = useRef<VisualSocketClient | null>(null)
@@ -2397,26 +2399,47 @@ export default function App() {
<div className="topbar-center"> <div className="topbar-center">
<div className="page-nav"> <div className="page-nav">
<button className={`page-nav-btn${activePage === 'workspace' ? ' active' : ''}`} onClick={() => setActivePage('workspace')}> <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) 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 return total > 0 ? <span className="nav-unread-badge">{total > 99 ? '99+' : total}</span> : null
})()} })()}
</button> </button>
<button className={`page-nav-btn${activePage === 'office' ? ' active' : ''}`} onClick={() => setActivePage('office')}>Office</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')}>Org</button> <button className={`page-nav-btn${activePage === 'org' ? ' active' : ''}`} onClick={() => setActivePage('org')}>{t('app.page.org')}</button>
</div> </div>
<div className="stat-chips"> <div className="stat-chips">
<span className="stat-chip"><b>{metrics.totalAgents}</b> agents</span> <span className="stat-chip"><b>{metrics.totalAgents}</b> {t('app.metric.agents')}</span>
<span className="stat-chip"><b>{metrics.totalSkills}</b> skills</span> <span className="stat-chip"><b>{metrics.totalSkills}</b> {t('app.metric.skills')}</span>
<span className="stat-chip"><b>{boardStore.getOpenTaskCount()}</b> tasks</span> <span className="stat-chip"><b>{boardStore.getOpenTaskCount()}</b> {t('app.metric.tasks')}</span>
</div> </div>
</div> </div>
<div className="topbar-right"> <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 <select
className="theme-select" className="theme-select"
value={outdoorOverride} value={outdoorOverride}
title="Outdoor lighting override" title={t('outdoor.title')}
aria-label={t('outdoor.title')}
onChange={(e) => { onChange={(e) => {
const v = e.target.value as 'auto' | 'day' | 'night' const v = e.target.value as 'auto' | 'day' | 'night'
setOutdoorOverride(v) setOutdoorOverride(v)
@@ -2434,20 +2457,20 @@ export default function App() {
bridgeRef.current.syncOutdoorLighting() bridgeRef.current.syncOutdoorLighting()
}} }}
> >
<option value="auto">Outdoor auto</option> <option value="auto">{t('outdoor.auto')}</option>
<option value="day">Outdoor day</option> <option value="day">{t('outdoor.day')}</option>
<option value="night">Outdoor night</option> <option value="night">{t('outdoor.night')}</option>
</select> </select>
<select className="theme-select" value={theme} onChange={(e) => setTheme(e.target.value as ThemeName)}> <select className="theme-select" value={theme} onChange={(e) => setTheme(e.target.value as ThemeName)}>
<option value="midnight">Midnight</option> <option value="midnight">{t('theme.midnight')}</option>
<option value="neon">Neon</option> <option value="neon">{t('theme.neon')}</option>
<option value="paper">Paper</option> <option value="paper">{t('theme.paper')}</option>
<option value="retro">Retro</option> <option value="retro">{t('theme.retro')}</option>
<option value="terminal">Terminal</option> <option value="terminal">{t('theme.terminal')}</option>
<option value="cozy">Cozy</option> <option value="cozy">{t('theme.cozy')}</option>
<option value="openopc">OpenOPC</option> <option value="openopc">{t('theme.openopc')}</option>
</select> </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> <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> </button>
</div> </div>
@@ -2602,14 +2625,14 @@ export default function App() {
{/* Phaser Game Canvas */} {/* Phaser Game Canvas */}
<section className="canvas-wrap"> <section className="canvas-wrap">
<PhaserGame bridge={bridgeRef.current} active={activePage === 'office'} /> <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 ? '👥' : '👤'} {showSubagents ? '👥' : '👤'}
</button> </button>
<button <button
className="sidebar-collapse-btn" className="sidebar-collapse-btn"
onClick={toggleSidebar} onClick={toggleSidebar}
title={sidebarCollapsed ? 'Show side panel' : 'Hide side panel'} title={sidebarCollapsed ? t('office.showSidePanel') : t('office.hideSidePanel')}
aria-label={sidebarCollapsed ? 'Show side panel' : 'Hide side panel'} aria-label={sidebarCollapsed ? t('office.showSidePanel') : t('office.hideSidePanel')}
> >
<span className="collapse-glyph">{sidebarCollapsed ? '' : ''}</span> <span className="collapse-glyph">{sidebarCollapsed ? '' : ''}</span>
</button> </button>
@@ -2624,13 +2647,13 @@ export default function App() {
<div className="mode-info-bar"> <div className="mode-info-bar">
<span className="mode-badge">{globalExecMode === 'company' ? `${globalExecMode}/${globalCompanyProfile}` : globalModeLabel}</span> <span className="mode-badge">{globalExecMode === 'company' ? `${globalExecMode}/${globalCompanyProfile}` : globalModeLabel}</span>
{isOrgMode ? ( {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>
<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"> <div className="office-cards">
{offices.map((office) => { {offices.map((office) => {
const deskCount = getOfficeDeskSeats(office.id).length const deskCount = getOfficeDeskSeats(office.id).length
@@ -2652,14 +2675,14 @@ export default function App() {
) : ( ) : (
<> <>
<span className="office-name">{office.name}</span> <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> <span className="office-capacity">{assignedCards.length}/{deskCount}</span>
</div> </div>
<div className="office-agents"> <div className="office-agents">
{assignedCards.map(c => ( {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)} {c.displayName.slice(0, 8)}
</span> </span>
))} ))}
@@ -2670,7 +2693,7 @@ export default function App() {
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
onChange={e => { if (e.target.value) handleAssignAgent(office.id, e.target.value) }} 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 => ( {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> <option key={a.id} value={a.id}>{a.displayName} ({offices.find(o => o.id === (cards.find(cc => cc.id === a.id)?.officeId))?.name ?? '?'})</option>
))} ))}
@@ -2682,7 +2705,7 @@ export default function App() {
})} })}
</div> </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"> <div className="agent-list">
{swarmAgents.map((agent) => ( {swarmAgents.map((agent) => (
<div key={agent.agent_id} className={`agent-row ${selectedAgentId === agent.agent_id ? 'selected' : ''}`}> <div key={agent.agent_id} className={`agent-row ${selectedAgentId === agent.agent_id ? 'selected' : ''}`}>
@@ -2690,7 +2713,7 @@ export default function App() {
<span className={`dot ${agent.status}`} /> <span className={`dot ${agent.status}`} />
<div className="agent-info"> <div className="agent-info">
<span className="agent-name">{agent.name}</span> <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> </div>
</button> </button>
{isOrgMode && ( {isOrgMode && (
@@ -2698,27 +2721,27 @@ export default function App() {
? <span className="agent-del" style={{ pointerEvents: 'none' }}><span className="spinner-inline" /></span> ? <span className="agent-del" style={{ pointerEvents: 'none' }}><span className="spinner-inline" /></span>
: confirmDeleteId === agent.agent_id : confirmDeleteId === agent.agent_id
? <span className="del-confirm"> ? <span className="del-confirm">
<span className="del-confirm-label">Delete?</span> <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) }}>Yes</button> <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)}>No</button> <button className="del-confirm-no" onClick={() => setConfirmDeleteId(null)}>{t('common.no')}</button>
</span> </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> </div>
))} ))}
{swarmAgents.length === 0 && ( {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> </div>
{selectedCard && ( {selectedCard && (
<div className="agent-detail"> <div className="agent-detail">
<div className="agent-detail-name">{selectedCard.displayName}</div> <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">{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">Tool</span><span className="detail-value">{selectedCard.currentTool ?? '—'}</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">Task</span><span className="detail-value">{selectedCard.taskSummary ?? '—'}</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"> <div className="agent-detail-row">
<span className="detail-label">Office</span> <span className="detail-label">{t('app.page.office')}</span>
<select <select
className="detail-select" className="detail-select"
value={selectedCard.officeId} value={selectedCard.officeId}
@@ -2729,7 +2752,7 @@ export default function App() {
</select> </select>
</div> </div>
<div className="agent-detail-row"> <div className="agent-detail-row">
<span className="detail-label">Seat</span> <span className="detail-label">{t('common.seat')}</span>
<select <select
className="detail-select" className="detail-select"
value={selectedCard.seatId ?? ''} value={selectedCard.seatId ?? ''}
@@ -2754,8 +2777,8 @@ export default function App() {
{cards.length > swarmAgents.length && ( {cards.length > swarmAgents.length && (
<> <>
<div className="section-label"> <div className="section-label">
Characters {t('office.characters')}
<button className="inline-btn" onClick={() => setShowSubagents((v) => !v)}>{showSubagents ? 'hide sub' : 'show sub'}</button> <button className="inline-btn" onClick={() => setShowSubagents((v) => !v)}>{showSubagents ? t('office.hideSub') : t('office.showSub')}</button>
</div> </div>
<div className="agent-list"> <div className="agent-list">
{cards.filter((c) => !swarmAgents.some((a) => a.agent_id === c.id)).map((card) => ( {cards.filter((c) => !swarmAgents.some((a) => a.agent_id === c.id)).map((card) => (
@@ -2777,28 +2800,29 @@ export default function App() {
{showDevTools && ( {showDevTools && (
<div className="dev-overlay"> <div className="dev-overlay">
<div className="dev-header"> <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> <button className="icon-btn" onClick={() => setShowDevTools(false)}></button>
</div> </div>
<div className="dev-group"> <div className="dev-group">
<div className="dev-label">Connection</div> <div className="dev-label">{t('dev.connection')}</div>
<div className="input-row"> <div className="input-row">
<input value={wsUrlInput} onChange={(e) => setWsUrlInput(e.target.value)} placeholder="ws://..." /> <input value={wsUrlInput} onChange={(e) => setWsUrlInput(e.target.value)} placeholder="ws://..." />
<button className="send-btn" onClick={applyWsUrl}></button> <button className="send-btn" onClick={applyWsUrl}></button>
</div> </div>
</div> </div>
<div className="dev-group"> <div className="dev-group">
<div className="dev-label">Evolution Pipeline</div> <div className="dev-label">{t('dev.evolution')}</div>
<div className="evo-pipeline"> <div className="evo-pipeline">
{(['Trace', 'Reflect', 'Synthesize', 'Practice', 'Lifecycle'] as const).map((phase, i) => { {(['Trace', 'Reflect', 'Synthesize', 'Practice', 'Lifecycle'] as const).map((phase, i) => {
const key = phase.toLowerCase() as keyof typeof evolutionPhases const key = phase.toLowerCase() as keyof typeof evolutionPhases
const active = key in evolutionPhases ? evolutionPhases[key as 'trace' | 'reflect' | 'synthesize'] : false const active = key in evolutionPhases ? evolutionPhases[key as 'trace' | 'reflect' | 'synthesize'] : false
const phaseLabelKey = `dev.phase.${key}` as Parameters<typeof t>[0]
return ( return (
<div key={phase} className="evo-phase-group"> <div key={phase} className="evo-phase-group">
{i > 0 && <div className="evo-connector" />} {i > 0 && <div className="evo-connector" />}
<div className={`evo-node ${active ? 'active' : ''}`}> <div className={`evo-node ${active ? 'active' : ''}`}>
<div className="evo-dot" /> <div className="evo-dot" />
<span className="evo-label">{phase}</span> <span className="evo-label">{t(phaseLabelKey)}</span>
</div> </div>
</div> </div>
) )
@@ -2815,7 +2839,7 @@ export default function App() {
</div> </div>
<div className="dev-group"> <div className="dev-group">
<div className="dev-label"> <div className="dev-label">
Events {t('dev.events')}
<select className="inline-select" value={eventTypeFilter} onChange={(e) => setEventTypeFilter(e.target.value)}> <select className="inline-select" value={eventTypeFilter} onChange={(e) => setEventTypeFilter(e.target.value)}>
{eventTypes.map((type) => <option key={type} value={type}>{type}</option>)} {eventTypes.map((type) => <option key={type} value={type}>{type}</option>)}
</select> </select>
@@ -2833,7 +2857,7 @@ export default function App() {
</div> </div>
{Object.keys(snapshot?.channels ?? {}).length > 0 && ( {Object.keys(snapshot?.channels ?? {}).length > 0 && (
<div className="dev-group"> <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]) => ( {Object.entries(snapshot?.channels ?? {}).map(([name, info]) => (
<div className="list-row" key={name}> <div className="list-row" key={name}>
<span>{name}</span> <span>{name}</span>
@@ -4,6 +4,7 @@ import type { Session } from '../types/kanban'
import { IconPlus, IconSearch, IconActivity, IconShield, IconTrash, IconWorkItem } from './SvgIcons' import { IconPlus, IconSearch, IconActivity, IconShield, IconTrash, IconWorkItem } from './SvgIcons'
import { getSessionRuntimeStatus } from '../lib/sessionRuntime' import { getSessionRuntimeStatus } from '../lib/sessionRuntime'
import { deriveCompanyRuntimeDisplayStatus } from '../lib/workItemSessions' import { deriveCompanyRuntimeDisplayStatus } from '../lib/workItemSessions'
import { useI18n } from '../i18n'
interface SessionSidebarProps { interface SessionSidebarProps {
sessions: Session[] sessions: Session[]
@@ -35,12 +36,14 @@ function sessionDotClass(session: Session): string {
return STATUS_DOT[displayStatus] ?? 'status-pending' 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 const diff = Date.now() - ts
if (diff < 60_000) return 'just now' if (diff < 60_000) return t('session.justNow')
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m` if (diff < 3_600_000) return t('session.minutes', { count: Math.floor(diff / 60_000) })
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h` if (diff < 86_400_000) return t('session.hours', { count: Math.floor(diff / 3_600_000) })
return `${Math.floor(diff / 86_400_000)}d` return t('session.days', { count: Math.floor(diff / 86_400_000) })
} }
function dateGroup(ts: number): string { function dateGroup(ts: number): string {
@@ -53,6 +56,15 @@ function dateGroup(ts: number): string {
return 'Earlier' 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 { interface SessionTree {
session: Session session: Session
children: Session[] children: Session[]
@@ -104,6 +116,7 @@ function SessionItem({
onSelect: () => void onSelect: () => void
onDelete: () => void onDelete: () => void
}) { }) {
const { t, translateMaybe } = useI18n()
const [showDelete, setShowDelete] = useState(false) const [showDelete, setShowDelete] = useState(false)
const [confirming, setConfirming] = useState(false) const [confirming, setConfirming] = useState(false)
const agentLabel = session.assigneeIds.length > 0 const agentLabel = session.assigneeIds.length > 0
@@ -134,12 +147,12 @@ function SessionItem({
<span className="session-agent-tag">{agentLabel}</span> <span className="session-agent-tag">{agentLabel}</span>
)} )}
{!isChild && session.isCompanyRuntime && ( {!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} {session.title}
</span> </span>
<span className="session-item-meta"> <span className="session-item-meta">
{displayStatus} · {relativeTime(session.updatedAt)} {translateMaybe('kanban.status', displayStatus) || displayStatus} · {relativeTime(session.updatedAt, t)}
</span> </span>
</div> </div>
{!!unreadCount && unreadCount > 0 && ( {!!unreadCount && unreadCount > 0 && (
@@ -149,15 +162,15 @@ function SessionItem({
<button <button
className="session-delete-btn" className="session-delete-btn"
onClick={e => { e.stopPropagation(); setConfirming(true) }} onClick={e => { e.stopPropagation(); setConfirming(true) }}
title="Delete" title={t('common.delete')}
> >
<IconTrash /> <IconTrash />
</button> </button>
)} )}
{confirming && ( {confirming && (
<span className="session-confirm-delete" onClick={e => e.stopPropagation()}> <span className="session-confirm-delete" onClick={e => e.stopPropagation()}>
<button className="session-confirm-yes" onClick={() => { setConfirming(false); onDelete() }}>Delete</button> <button className="session-confirm-yes" onClick={() => { setConfirming(false); onDelete() }}>{t('common.delete')}</button>
<button className="session-confirm-no" onClick={() => setConfirming(false)}>Cancel</button> <button className="session-confirm-no" onClick={() => setConfirming(false)}>{t('common.cancel')}</button>
</span> </span>
)} )}
</button> </button>
@@ -165,6 +178,7 @@ function SessionItem({
} }
export function SessionSidebar({ sessions, activeSessionId, activeChannel, secretaryChannelId, unreadCounts, onSelect, onCreateSession, onDeleteSession, onSelectSecretary }: SessionSidebarProps) { export function SessionSidebar({ sessions, activeSessionId, activeChannel, secretaryChannelId, unreadCounts, onSelect, onCreateSession, onDeleteSession, onSelectSecretary }: SessionSidebarProps) {
const { t } = useI18n()
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [collapsed, setCollapsed] = useState<Set<string>>(new Set()) const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
const hasRuntimeSessions = sessions.some(session => const hasRuntimeSessions = sessions.some(session =>
@@ -256,7 +270,7 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
e.stopPropagation() e.stopPropagation()
toggleCollapse(node.session.taskId) toggleCollapse(node.session.taskId)
}} }}
aria-label={isCollapsed ? 'Expand' : 'Collapse'} aria-label={isCollapsed ? t('session.expand') : t('session.collapse')}
/> />
)} )}
<SessionItem <SessionItem
@@ -269,13 +283,13 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
</div> </div>
</div> </div>
) )
}, [activeSessionId, collapsed, onDeleteSession, onSelect, toggleCollapse, unreadCounts]) }, [activeSessionId, collapsed, onDeleteSession, onSelect, toggleCollapse, unreadCounts, t])
const renderVirtualRow = useCallback((row: SidebarRow) => { const renderVirtualRow = useCallback((row: SidebarRow) => {
if (row.kind === 'group') { if (row.kind === 'group') {
return ( return (
<div className="session-group-label"> <div className="session-group-label">
{hasRuntimeSessions ? `${row.group} Runtime Sessions` : row.group} {groupLabel(row.group, hasRuntimeSessions, t)}
</div> </div>
) )
} }
@@ -288,7 +302,7 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
className="session-child-count" className="session-child-count"
onClick={() => toggleCollapse(row.node.session.taskId)} 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> </button>
) )
} }
@@ -305,13 +319,13 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
/> />
</div> </div>
) )
}, [activeSessionId, hasRuntimeSessions, onDeleteSession, onSelect, renderPrimaryRow, toggleCollapse, unreadCounts]) }, [activeSessionId, hasRuntimeSessions, onDeleteSession, onSelect, renderPrimaryRow, toggleCollapse, unreadCounts, t])
return ( return (
<div className="session-sidebar"> <div className="session-sidebar">
<button className="session-new-btn" onClick={onCreateSession}> <button className="session-new-btn" onClick={onCreateSession}>
<IconPlus /> <IconPlus />
<span>New Chat</span> <span>{t('session.newChat')}</span>
</button> </button>
{onSelectSecretary && ( {onSelectSecretary && (
@@ -320,7 +334,7 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
onClick={onSelectSecretary} onClick={onSelectSecretary}
> >
<IconShield /> <IconShield />
<span>Secretary</span> <span>{t('session.secretary')}</span>
{!!(secretaryChannelId && unreadCounts?.[secretaryChannelId]) && unreadCounts![secretaryChannelId!] > 0 && ( {!!(secretaryChannelId && unreadCounts?.[secretaryChannelId]) && unreadCounts![secretaryChannelId!] > 0 && (
<span className="session-unread-badge">{unreadCounts![secretaryChannelId!] > 99 ? '99+' : unreadCounts![secretaryChannelId!]}</span> <span className="session-unread-badge">{unreadCounts![secretaryChannelId!] > 99 ? '99+' : unreadCounts![secretaryChannelId!]}</span>
)} )}
@@ -331,7 +345,7 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
<IconSearch /> <IconSearch />
<input <input
className="session-search" className="session-search"
placeholder="Search..." placeholder={t('session.search')}
value={search} value={search}
onChange={e => setSearch(e.target.value)} onChange={e => setSearch(e.target.value)}
/> />
@@ -368,7 +382,7 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
if (!items || items.length === 0) return null if (!items || items.length === 0) return null
return ( return (
<div key={group} className="session-group"> <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 => { {items.map(node => {
const hasChildren = node.children.length > 0 const hasChildren = node.children.length > 0
const isCollapsed = collapsed.has(node.session.taskId) const isCollapsed = collapsed.has(node.session.taskId)
@@ -382,7 +396,7 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
e.stopPropagation() e.stopPropagation()
toggleCollapse(node.session.taskId) toggleCollapse(node.session.taskId)
}} }}
aria-label={isCollapsed ? 'Expand' : 'Collapse'} aria-label={isCollapsed ? t('session.expand') : t('session.collapse')}
/> />
)} )}
<SessionItem <SessionItem
@@ -416,7 +430,7 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
className="session-child-count" className="session-child-count"
onClick={() => toggleCollapse(node.session.taskId)} 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> </button>
)} )}
</div> </div>
@@ -427,7 +441,7 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
})} })}
{filtered.length === 0 && ( {filtered.length === 0 && (
<div className="session-empty">No sessions yet</div> <div className="session-empty">{t('session.noSessions')}</div>
)} )}
</div> </div>
@@ -436,7 +450,7 @@ export function SessionSidebar({ sessions, activeSessionId, activeChannel, secre
onClick={() => onSelect(null)} onClick={() => onSelect(null)}
> >
<IconActivity /> <IconActivity />
<span>Activity</span> <span>{t('session.activity')}</span>
</button> </button>
</div> </div>
) )
@@ -1,5 +1,6 @@
import { useState, useCallback } from 'react' import { useState, useCallback } from 'react'
import type { Project } from '../types/kanban' import type { Project } from '../types/kanban'
import { useI18n } from '../i18n'
interface ProjectSelectorProps { interface ProjectSelectorProps {
projects: Project[] projects: Project[]
@@ -10,6 +11,7 @@ interface ProjectSelectorProps {
} }
export function ProjectSelector({ projects, activeId, onSelect, onCreate, onDelete }: ProjectSelectorProps) { export function ProjectSelector({ projects, activeId, onSelect, onCreate, onDelete }: ProjectSelectorProps) {
const { t } = useI18n()
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const [newName, setNewName] = useState('') const [newName, setNewName] = useState('')
const [confirmDelete, setConfirmDelete] = useState<string | null>(null) const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
@@ -34,7 +36,8 @@ export function ProjectSelector({ projects, activeId, onSelect, onCreate, onDele
className="theme-select" className="theme-select"
value={activeId} value={activeId}
onChange={e => onSelect(e.target.value)} onChange={e => onSelect(e.target.value)}
title="Switch project" title={t('project.switch')}
aria-label={t('project.switch')}
> >
{projects.map(p => ( {projects.map(p => (
<option key={p.id} value={p.id}>{p.name}</option> <option key={p.id} value={p.id}>{p.name}</option>
@@ -49,7 +52,7 @@ export function ProjectSelector({ projects, activeId, onSelect, onCreate, onDele
autoFocus autoFocus
className="theme-select" className="theme-select"
value={newName} value={newName}
placeholder="project-name" placeholder={t('project.placeholder')}
onChange={e => setNewName(e.target.value)} onChange={e => setNewName(e.target.value)}
onKeyDown={e => { if (e.key === 'Escape') setCreating(false) }} onKeyDown={e => { if (e.key === 'Escape') setCreating(false) }}
style={{ width: 120 }} style={{ width: 120 }}
@@ -60,7 +63,7 @@ export function ProjectSelector({ projects, activeId, onSelect, onCreate, onDele
<button <button
className="pill-btn" className="pill-btn"
onClick={() => setCreating(true)} onClick={() => setCreating(true)}
title="New project" title={t('project.new')}
style={{ fontSize: 11, padding: '2px 8px' }} style={{ fontSize: 11, padding: '2px 8px' }}
> >
+ +
@@ -70,10 +73,10 @@ export function ProjectSelector({ projects, activeId, onSelect, onCreate, onDele
<button <button
className="pill-btn" className="pill-btn"
onClick={() => setConfirmDelete(activeId)} onClick={() => setConfirmDelete(activeId)}
title="Delete project" title={t('project.delete')}
style={{ fontSize: 11, padding: '2px 8px', color: '#ef4444' }} style={{ fontSize: 11, padding: '2px 8px', color: '#ef4444' }}
> >
Del {t('project.deleteButton')}
</button> </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', maxWidth: 400, boxShadow: '0 8px 32px rgba(0,0,0,0.4)', textAlign: 'center',
}}> }}>
<p style={{ margin: '0 0 8px', fontWeight: 600, fontSize: 15 }}> <p style={{ margin: '0 0 8px', fontWeight: 600, fontSize: 15 }}>
Delete project "{confirmDelete}"? {t('project.confirmTitle', { name: confirmDelete })}
</p> </p>
<p style={{ margin: '0 0 20px', fontSize: 13, opacity: 0.7 }}> <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> </p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}> <div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
<button <button
@@ -99,14 +102,14 @@ export function ProjectSelector({ projects, activeId, onSelect, onCreate, onDele
onClick={() => setConfirmDelete(null)} onClick={() => setConfirmDelete(null)}
style={{ padding: '6px 18px', fontSize: 13 }} style={{ padding: '6px 18px', fontSize: 13 }}
> >
Cancel {t('common.cancel')}
</button> </button>
<button <button
className="pill-btn" className="pill-btn"
onClick={handleDelete} onClick={handleDelete}
style={{ padding: '6px 18px', fontSize: 13, background: '#ef4444', color: '#fff' }} style={{ padding: '6px 18px', fontSize: 13, background: '#ef4444', color: '#fff' }}
> >
Delete {t('common.delete')}
</button> </button>
</div> </div>
</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; 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 { .theme-select {
font-size: 11px; font-size: 11px;
padding: 4px 8px; padding: 4px 8px;
@@ -2,6 +2,7 @@ import { useMemo } from 'react'
import type { AgentInfo } from '../types/visual' import type { AgentInfo } from '../types/visual'
import type { AgentAnimStatus, KanbanTask } from '../types/kanban' import type { AgentAnimStatus, KanbanTask } from '../types/kanban'
import { AGENT_STATUS_LABEL } from '../types/kanban' import { AGENT_STATUS_LABEL } from '../types/kanban'
import { useI18n } from '../i18n'
interface AgentStatusBarProps { interface AgentStatusBarProps {
agents: AgentInfo[] agents: AgentInfo[]
@@ -16,6 +17,7 @@ interface AgentState {
} }
export function AgentStatusBar({ agents, tasks }: AgentStatusBarProps) { export function AgentStatusBar({ agents, tasks }: AgentStatusBarProps) {
const { t, translateMaybe } = useI18n()
const agentStates = useMemo<AgentState[]>(() => { const agentStates = useMemo<AgentState[]>(() => {
const tasksById = new Map(tasks.map((task) => [task.id, task])) const tasksById = new Map(tasks.map((task) => [task.id, task]))
return agents.map(agent => { return agents.map(agent => {
@@ -42,15 +44,19 @@ export function AgentStatusBar({ agents, tasks }: AgentStatusBarProps) {
<div className="agent-status-bar"> <div className="agent-status-bar">
<span className="agent-status-summary"> <span className="agent-status-summary">
{activeCount > 0 {activeCount > 0
? `${activeCount}/${agents.length} active` ? t('agent.summary.active', { active: activeCount, total: agents.length })
: `${agents.length} agent${agents.length !== 1 ? 's' : ''}`} : t(agents.length !== 1 ? 'agent.summary.countPlural' : 'agent.summary.count', { count: agents.length })}
</span> </span>
<div className="agent-status-chips"> <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 <div
key={agent.agent_id} key={agent.agent_id}
className={`agent-status-chip status-${status}`} 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-avatar">{agent.name.charAt(0).toUpperCase()}</span>
<span className="agent-status-name">{agent.name}</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="kanban-runtime-dot" />
<span className="agent-status-detail"> <span className="agent-status-detail">
{status === 'tool_active' && currentTool ? currentTool : AGENT_STATUS_LABEL[status]} {statusLabel}
</span> </span>
</> </>
)} )}
@@ -66,7 +72,8 @@ export function AgentStatusBar({ agents, tasks }: AgentStatusBarProps) {
<span className="agent-status-task">{taskDisplayId}</span> <span className="agent-status-task">{taskDisplayId}</span>
)} )}
</div> </div>
))} )
})}
</div> </div>
</div> </div>
) )
@@ -1,4 +1,5 @@
import type { KanbanBoard } from '../types/kanban' import type { KanbanBoard } from '../types/kanban'
import { useI18n } from '../i18n'
interface BoardSelectorProps { interface BoardSelectorProps {
boards: KanbanBoard[] boards: KanbanBoard[]
@@ -7,6 +8,7 @@ interface BoardSelectorProps {
} }
export function BoardSelector({ boards, activeBoardId, onSelect }: BoardSelectorProps) { export function BoardSelector({ boards, activeBoardId, onSelect }: BoardSelectorProps) {
const { translateMaybe } = useI18n()
return ( return (
<div className="board-selector"> <div className="board-selector">
<div className="board-tabs"> <div className="board-tabs">
@@ -18,7 +20,7 @@ export function BoardSelector({ boards, activeBoardId, onSelect }: BoardSelector
onClick={() => onSelect(b.id)} onClick={() => onSelect(b.id)}
> >
<span className="board-tab-dot" style={{ background: b.color }} /> <span className="board-tab-dot" style={{ background: b.color }} />
{b.name} {translateMaybe('kanban.column', b.name) || b.name}
</button> </button>
))} ))}
</div> </div>
@@ -3,6 +3,7 @@ import type { AgentInfo } from '../types/visual'
import { PRIORITY_META, AGENT_STATUS_LABEL, type KanbanTask } from '../types/kanban' import { PRIORITY_META, AGENT_STATUS_LABEL, type KanbanTask } from '../types/kanban'
import { getWorkItemRoleLabel, humanizeWorkItemRoleId } from '../lib/workItemIdentity' import { getWorkItemRoleLabel, humanizeWorkItemRoleId } from '../lib/workItemIdentity'
import { getLinkedRuntimeTaskId } from '../lib/workItemRuntimeIds' import { getLinkedRuntimeTaskId } from '../lib/workItemRuntimeIds'
import { useI18n } from '../i18n'
const STATUS_BADGE: Record<string, { label: string; color: string }> = { const STATUS_BADGE: Record<string, { label: string; color: string }> = {
todo: { label: 'To do', color: '#9ca3af' }, todo: { label: 'To do', color: '#9ca3af' },
@@ -32,6 +33,7 @@ interface KanbanCardProps {
} }
export function KanbanCard({ task, index, agents, officeMap, companyMode, isSelected, onClick, onStart }: KanbanCardProps) { export function KanbanCard({ task, index, agents, officeMap, companyMode, isSelected, onClick, onStart }: KanbanCardProps) {
const { t, translateMaybe } = useI18n()
const assignees = task.assigneeIds const assignees = task.assigneeIds
.map(id => agents.find(a => a.agent_id === id)) .map(id => agents.find(a => a.agent_id === id))
.filter(Boolean) as AgentInfo[] .filter(Boolean) as AgentInfo[]
@@ -54,6 +56,9 @@ export function KanbanCard({ task, index, agents, officeMap, companyMode, isSele
const blockerLabel = (task.blockedReason ?? '').trim() const blockerLabel = (task.blockedReason ?? '').trim()
const reworkLabel = (task.reworkFeedback ?? '').trim() const reworkLabel = (task.reworkFeedback ?? '').trim()
const linkedRuntimeTaskId = getLinkedRuntimeTaskId(task) const linkedRuntimeTaskId = getLinkedRuntimeTaskId(task)
const runtimeStatusLabel = task.agentStatus
? (translateMaybe('agent.status', task.agentStatus) || AGENT_STATUS_LABEL[task.agentStatus] || task.agentStatus)
: ''
return ( return (
<Draggable draggableId={task.id} index={index} isDragDisabled={!!companyMode}> <Draggable draggableId={task.id} index={index} isDragDisabled={!!companyMode}>
@@ -71,17 +76,17 @@ export function KanbanCard({ task, index, agents, officeMap, companyMode, isSele
{statusBadge && ( {statusBadge && (
<span className="kanban-status-badge" style={{ color: statusBadge.color }}> <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 }} /> <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> </span>
)} )}
{depCount > 0 && ( {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 && ( {onStart && (
<button <button
className="kanban-start-btn" 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) }} onClick={e => { e.stopPropagation(); onStart(task.id) }}
> >
&#x25B6; &#x25B6;
@@ -94,34 +99,34 @@ export function KanbanCard({ task, index, agents, officeMap, companyMode, isSele
{(roleLabel || employee?.name || gate?.type || task.originChannel || task.workItemProjectionId) && ( {(roleLabel || employee?.name || gate?.type || task.originChannel || task.workItemProjectionId) && (
<div className="kanban-card-meta-row"> <div className="kanban-card-meta-row">
{roleLabel && ( {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} {roleLabel}
</span> </span>
)} )}
{employee?.name && ( {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> <span className="kanban-meta-icon">&#x1F464;</span>
{employee.name} {employee.name}
</span> </span>
)} )}
{task.workItemProjectionId && ( {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} {task.workItemProjectionId}
</span> </span>
)} )}
{gate?.type && ( {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 === 'review' ? '\u2709' : gate.type === 'approval' ? '\u2713' : '\u270B'}
{gate.type} {gate.type}
</span> </span>
)} )}
{task.originChannel && ( {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} #{task.originChannel}
</span> </span>
)} )}
{managerLabel && ( {managerLabel && (
<span className="kanban-meta-badge" title={`Manager: ${managerLabel}`}> <span className="kanban-meta-badge" title={`${t('common.manager')}: ${managerLabel}`}>
{managerLabel} {managerLabel}
</span> </span>
)} )}
@@ -133,8 +138,8 @@ export function KanbanCard({ task, index, agents, officeMap, companyMode, isSele
{blockerLabel && <span className="kanban-tag">{blockerLabel}</span>} {blockerLabel && <span className="kanban-tag">{blockerLabel}</span>}
{reworkLabel && <span className="kanban-tag">{reworkLabel}</span>} {reworkLabel && <span className="kanban-tag">{reworkLabel}</span>}
{linkedRuntimeTaskId && ( {linkedRuntimeTaskId && (
<span className="kanban-tag" title={`Execution Turn: ${linkedRuntimeTaskId}`}> <span className="kanban-tag" title={t('kanban.executionTurn', { id: linkedRuntimeTaskId })}>
Runtime {t('common.runtime')}
</span> </span>
)} )}
</div> </div>
@@ -146,7 +151,7 @@ export function KanbanCard({ task, index, agents, officeMap, companyMode, isSele
<span className="kanban-runtime-label"> <span className="kanban-runtime-label">
{task.agentStatus === 'tool_active' && task.currentTool {task.agentStatus === 'tool_active' && task.currentTool
? task.currentTool ? task.currentTool
: AGENT_STATUS_LABEL[task.agentStatus!] ?? task.agentStatus} : runtimeStatusLabel}
</span> </span>
</div> </div>
)} )}
@@ -3,6 +3,7 @@ import { Droppable } from '@hello-pangea/dnd'
import type { AgentInfo } from '../types/visual' import type { AgentInfo } from '../types/visual'
import type { KanbanColumn as KanbanColumnType, KanbanTask } from '../types/kanban' import type { KanbanColumn as KanbanColumnType, KanbanTask } from '../types/kanban'
import { KanbanCard } from './KanbanCard' import { KanbanCard } from './KanbanCard'
import { useI18n } from '../i18n'
interface KanbanColumnProps { interface KanbanColumnProps {
column: KanbanColumnType column: KanbanColumnType
@@ -17,6 +18,7 @@ interface KanbanColumnProps {
} }
export function KanbanColumn({ column, tasks, agents, officeMap, companyMode, selectedTaskId, onCardClick, onStartTask, onQuickCreate }: KanbanColumnProps) { export function KanbanColumn({ column, tasks, agents, officeMap, companyMode, selectedTaskId, onCardClick, onStartTask, onQuickCreate }: KanbanColumnProps) {
const { t, translateMaybe } = useI18n()
const [adding, setAdding] = useState(false) const [adding, setAdding] = useState(false)
const [draft, setDraft] = useState('') const [draft, setDraft] = useState('')
const committedRef = useRef(false) 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">
<div className="kanban-column-header"> <div className="kanban-column-header">
<span className="kanban-col-dot" style={{ background: column.color }} /> <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> <span className="kanban-col-count">{tasks.length}</span>
{onQuickCreate && ( {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> </div>
@@ -54,7 +56,7 @@ export function KanbanColumn({ column, tasks, agents, officeMap, companyMode, se
if (e.key === 'Escape') { committedRef.current = true; setDraft(''); setAdding(false) } if (e.key === 'Escape') { committedRef.current = true; setDraft(''); setAdding(false) }
}} }}
onBlur={commitAdd} onBlur={commitAdd}
placeholder="Task title..." placeholder={t('kanban.taskTitle')}
autoFocus autoFocus
/> />
</div> </div>
@@ -1,6 +1,7 @@
import React from 'react' import React from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import App from './App' import App from './App'
import { I18nProvider } from './i18n'
import './index.css' import './index.css'
class ErrorBoundary extends React.Component< class ErrorBoundary extends React.Component<
@@ -66,7 +67,9 @@ if (!root) {
try { try {
createRoot(root).render( createRoot(root).render(
<ErrorBoundary> <ErrorBoundary>
<I18nProvider>
<App /> <App />
</I18nProvider>
</ErrorBoundary> </ErrorBoundary>
) )
} catch (err) { } catch (err) {
@@ -18,6 +18,7 @@ import { EmployeesMarketplace } from './EmployeesMarketplace'
import { ConfigImportExportPanel } from './ConfigImportExportPanel' import { ConfigImportExportPanel } from './ConfigImportExportPanel'
import { OrgCreateModal } from './OrgCreateModal' import { OrgCreateModal } from './OrgCreateModal'
import { getRuntimeOrgView } from '../lib/runtimeOrg' import { getRuntimeOrgView } from '../lib/runtimeOrg'
import { useI18n } from '../i18n'
import './org.css' import './org.css'
import './team.css' import './team.css'
import './marketplace.css' import './marketplace.css'
@@ -122,6 +123,7 @@ export function OrgTab({
onSavedOrgsList, onSavedOrgSaveAs, onSavedOrgCreate, onSavedOrgLoad, onSavedOrgDelete, savedOrgsList, onSavedOrgsList, onSavedOrgSaveAs, onSavedOrgCreate, onSavedOrgLoad, onSavedOrgDelete, savedOrgsList,
activeSavedOrg, activeSavedOrgVersionAtLoad, orgCreatePending, orgCreateResult, onSelectCorporate, activeSavedOrg, activeSavedOrgVersionAtLoad, orgCreatePending, orgCreateResult, onSelectCorporate,
}: OrgTabProps) { }: OrgTabProps) {
const { t } = useI18n()
const [activeTab, setActiveTab] = useState<SubTab>('team') const [activeTab, setActiveTab] = useState<SubTab>('team')
const [createOpen, setCreateOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
@@ -166,8 +168,8 @@ export function OrgTab({
if (!orgCreateResult || !orgCreateResult.ok) return if (!orgCreateResult || !orgCreateResult.ok) return
setCreateOpen(false) setCreateOpen(false)
setActiveTab('team') setActiveTab('team')
showToast(`Created ${orgCreateResult.organization_name || orgCreateResult.name} and saved automatically`) showToast(t('org.createdToast', { name: orgCreateResult.organization_name || orgCreateResult.name }))
}, [orgCreateResult, showToast]) }, [orgCreateResult, showToast, t])
// In org mode: show only user-owned roles. In company mode: show all roles read-only. // In org mode: show only user-owned roles. In company mode: show all roles read-only.
const allRoles = data?.roles ?? [] const allRoles = data?.roles ?? []
@@ -176,13 +178,13 @@ export function OrgTab({
const runtimeView = useMemo(() => getRuntimeOrgView(data), [data]) const runtimeView = useMemo(() => getRuntimeOrgView(data), [data])
const activeAgents = useMemo(() => displayEmployees.filter(e => e.linked_agent_id), [displayEmployees]) const activeAgents = useMemo(() => displayEmployees.filter(e => e.linked_agent_id), [displayEmployees])
const configuredOrgName = data?.organization_name?.trim() 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 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 const architectureStateLabel = isCustomMode
? activeSavedOrg ? 'Editable saved architecture' : 'Editable draft architecture' ? activeSavedOrg ? t('org.state.editableSaved') : t('org.state.editableDraft')
: 'Built-in read-only architecture' : t('org.state.readOnly')
const runtimeStateLabel = runtimeView.frontier.status || runtimeView.projectRun?.status || runtimeView.projectRun?.lifecycle_status || 'ready' 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. // Roles that already have at least one non-placeholder employee.
const filledRoleIds = useMemo( const filledRoleIds = useMemo(
@@ -210,9 +212,9 @@ export function OrgTab({
setApplyingPresetId(null) setApplyingPresetId(null)
versionAtApply.current = -1 versionAtApply.current = -1
setActiveTab('team') setActiveTab('team')
showToast('Architecture applied successfully') showToast(t('org.architectureApplied'))
} }
}, [orgVersion, applyingPresetId, showToast]) }, [orgVersion, applyingPresetId, showToast, t])
const handleApplyPreset = (presetId: string, strategy: string) => { const handleApplyPreset = (presetId: string, strategy: string) => {
versionAtApply.current = orgVersion // snapshot current version versionAtApply.current = orgVersion // snapshot current version
@@ -232,7 +234,7 @@ export function OrgTab({
} }
if (!data) { 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 ?? [] 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${isCustomMode ? ' org-header--custom' : ' org-header--corporate'}`}>
<div className="org-header-main"> <div className="org-header-main">
<div className="org-eyebrow"> <div className="org-eyebrow">
<span>Company</span> <span>{t('org.company')}</span>
<span className="org-eyebrow-separator">/</span> <span className="org-eyebrow-separator">/</span>
<span>{architectureKindLabel}</span> <span>{architectureKindLabel}</span>
</div> </div>
@@ -259,13 +261,13 @@ export function OrgTab({
<span className="org-meta-pill">{data.company_profile || (isCustomMode ? 'custom' : 'corporate')}</span> <span className="org-meta-pill">{data.company_profile || (isCustomMode ? 'custom' : 'corporate')}</span>
{activeOrgId && <code className="org-meta-code">{activeOrgId}</code>} {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--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> </div>
<div className="org-control-panel"> <div className="org-control-panel">
<label className="org-switcher"> <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"> <span className="org-switcher-select-wrap">
<select <select
className="org-switcher-select" className="org-switcher-select"
@@ -273,9 +275,9 @@ export function OrgTab({
onChange={e => handleOrgSelection(e.target.value)} onChange={e => handleOrgSelection(e.target.value)}
onFocus={() => onSavedOrgsList?.()} onFocus={() => onSavedOrgsList?.()}
onPointerDown={() => 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 => ( {savedOrgOptions.map(org => (
<option key={org.name} value={`org:${org.name}`}> <option key={org.name} value={`org:${org.name}`}>
{(org.organization_name || org.name).trim() || org.name} {(org.organization_name || org.name).trim() || org.name}
@@ -286,36 +288,36 @@ export function OrgTab({
</label> </label>
<button type="button" className="org-create-trigger" onClick={() => setCreateOpen(true)}> <button type="button" className="org-create-trigger" onClick={() => setCreateOpen(true)}>
<span className="org-create-trigger-icon" aria-hidden>+</span> <span className="org-create-trigger-icon" aria-hidden>+</span>
New organization {t('org.newOrganization')}
</button> </button>
</div> </div>
<div className="org-stats-strip"> <div className="org-stats-strip">
<span className="org-stat"> <span className="org-stat">
<img src={STAT_ICON.agents} alt="" className="org-stat-icon" /> <img src={STAT_ICON.agents} alt="" className="org-stat-icon" />
<b>{displayRoles.length}</b> roles <b>{displayRoles.length}</b> {t('org.roles')}
</span> </span>
<span className="org-stat"> <span className="org-stat">
<img src={TAB_ICON.employees} alt="" className="org-stat-icon" /> <img src={TAB_ICON.employees} alt="" className="org-stat-icon" />
<b>{displayEmployees.length}</b> employees <b>{displayEmployees.length}</b> {t('org.employees')}
</span> </span>
<span className="org-stat"> <span className="org-stat">
<img src={TAB_ICON.runtime} alt="" className="org-stat-icon" /> <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>
<span className="org-stat"> <span className="org-stat">
<img src={STAT_ICON.active} alt="" className="org-stat-icon" /> <img src={STAT_ICON.active} alt="" className="org-stat-icon" />
<b>{activeAgents.length}</b> active <b>{activeAgents.length}</b> {t('common.active')}
</span> </span>
</div> </div>
</div> </div>
<div className="org-subtabs"> <div className="org-subtabs">
{([ {([
{ id: 'team' as SubTab, icon: TAB_ICON.team, label: 'Team', count: displayRoles.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: 'Runtime', count: runtimeView.runtimeTeams.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: 'Architecture', count: marketPresets?.length ?? 0 }, { 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: 'Employees', count: talents.length }, { id: 'employees' as SubTab, icon: TAB_ICON.employees, label: t('org.tab.employees'), count: talents.length },
]).map(tab => ( ]).map(tab => (
<button key={tab.id} <button key={tab.id}
className={`org-subtab${activeTab === tab.id ? ' org-subtab--active' : ''}`} className={`org-subtab${activeTab === tab.id ? ' org-subtab--active' : ''}`}
@@ -22,6 +22,7 @@ import { getRuntimeOrgView } from '../lib/runtimeOrg'
import { getLinkedRuntimeTaskId } from '../lib/workItemRuntimeIds' import { getLinkedRuntimeTaskId } from '../lib/workItemRuntimeIds'
import { ContextPanel } from './ContextPanel' import { ContextPanel } from './ContextPanel'
import { useResizePanel } from './useResizePanel' import { useResizePanel } from './useResizePanel'
import { useI18n } from '../i18n'
type ActiveView = type ActiveView =
| { kind: 'session'; taskId: string } | { kind: 'session'; taskId: string }
@@ -42,11 +43,25 @@ function makeOptimisticUserMessageId(): string {
/* ── Org mode pre-run readiness check ─────────────────────────────────── */ /* ── Org mode pre-run readiness check ─────────────────────────────────── */
type TFunction = ReturnType<typeof useI18n>['t']
function checkOrgModeReadiness( function checkOrgModeReadiness(
mode: string, mode: string,
orgInfoData: OrgInfoPayload | null | undefined, orgInfoData: OrgInfoPayload | null | undefined,
onNavigateToOrg?: () => void, onNavigateToOrg?: () => void,
t?: TFunction,
): boolean { ): 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 (mode !== 'org' && mode !== 'custom') return true
if (!orgInfoData) return true if (!orgInfoData) return true
@@ -57,21 +72,13 @@ function checkOrgModeReadiness(
// Block: no roles defined // Block: no roles defined
if (roles.length === 0) { if (roles.length === 0) {
const goToOrg = confirm( const goToOrg = confirm(tr('workspace.confirmNoRoles'))
'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?'
)
if (goToOrg) onNavigateToOrg?.() if (goToOrg) onNavigateToOrg?.()
return false return false
} }
if (topLevelRoleIds.length > 1 && !finalDeciderRoleId) { if (topLevelRoleIds.length > 1 && !finalDeciderRoleId) {
const goToOrg = confirm( const goToOrg = confirm(tr('workspace.confirmNoDecider'))
'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?'
)
if (goToOrg) onNavigateToOrg?.() if (goToOrg) onNavigateToOrg?.()
return false return false
} }
@@ -84,7 +91,7 @@ function checkOrgModeReadiness(
|| (team.manager_role_id && roleIds.has(team.manager_role_id)) || (team.manager_role_id && roleIds.has(team.manager_role_id))
)) ))
if (relevantTeams.length === 0) { 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 // Warn: roles without employees
@@ -92,15 +99,11 @@ function checkOrgModeReadiness(
const vacantRoles = roles.filter(r => !employeeRoleIds.has(r.role_id)) const vacantRoles = roles.filter(r => !employeeRoleIds.has(r.role_id))
if (vacantRoles.length > 0) { if (vacantRoles.length > 0) {
const names = vacantRoles.map(r => r.name).join(', ') 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) { if (warnings.length > 0) {
return confirm( return confirm(tr('workspace.confirmWarnings', { warnings: warnings.map(w => '\u2022 ' + w).join('\n') }))
'Before running this task:\n\n' +
warnings.map(w => '\u2022 ' + w).join('\n') +
'\n\nRun anyway?'
)
} }
return true return true
@@ -158,6 +161,7 @@ function BoardTitleEditor({
title: string title: string
onCommit: (next: string) => void onCommit: (next: string) => void
}) { }) {
const { t } = useI18n()
const [editing, setEditing] = useState(false) const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(title) const [draft, setDraft] = useState(title)
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
@@ -206,7 +210,7 @@ function BoardTitleEditor({
<span <span
className="board-tab-title" className="board-tab-title"
onClick={startEditing} onClick={startEditing}
title="Click to edit session title" title={t('workspace.clickEditTitle')}
> >
{title} {title}
</span> </span>
@@ -310,6 +314,7 @@ export function WorkspacePage({
onSavedOrgsList, onSavedOrgsList,
onSavedOrgLoad, onSavedOrgLoad,
}: WorkspacePageProps) { }: WorkspacePageProps) {
const { t } = useI18n()
const { sessions, activeSessionId, activeSession } = sessionStore const { sessions, activeSessionId, activeSession } = sessionStore
const { markRead } = chatStore const { markRead } = chatStore
@@ -933,7 +938,7 @@ export function WorkspacePage({
: undefined : undefined
// Pre-run readiness check for org mode // Pre-run readiness check for org mode
if (!checkOrgModeReadiness(sessionExecMode, orgInfoData, onNavigateToOrg)) { if (!checkOrgModeReadiness(sessionExecMode, orgInfoData, onNavigateToOrg, t)) {
return return
} }
@@ -948,7 +953,7 @@ export function WorkspacePage({
if (inProgressCol) { if (inProgressCol) {
boardStore.moveTask(taskId, inProgressCol.id, 0) 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) => { const handleSessionConfigChange = useCallback((taskId: string, sessionMode: string, sessionCompanyProfile?: string, orgId?: string) => {
onSessionConfigChange?.(taskId, sessionMode, sessionCompanyProfile, orgId) onSessionConfigChange?.(taskId, sessionMode, sessionCompanyProfile, orgId)
@@ -1094,7 +1099,7 @@ export function WorkspacePage({
}, [handleOpenWorkItemSession, onOpenExecutionPanel]) }, [handleOpenWorkItemSession, onOpenExecutionPanel])
const isSecretary = effectiveView.kind === 'secretary' 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 ( return (
<div className={`workspace-page${panelState === 'maximized' ? ' panel-maximized' : ''}`}> <div className={`workspace-page${panelState === 'maximized' ? ' panel-maximized' : ''}`}>
@@ -1137,7 +1142,7 @@ export function WorkspacePage({
)} )}
{isCompanyMode && !boardStore.activeBoard ? ( {isCompanyMode && !boardStore.activeBoard ? (
<div className="kanban-empty-state"> <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> </div>
) : ( ) : (
<> <>
@@ -1156,7 +1161,7 @@ export function WorkspacePage({
/> />
{isCompanyMode && boardStore.activeBoard && boardStore.tasks.filter(t => t.boardId === boardStore.activeBoardId).length === 0 && ( {isCompanyMode && boardStore.activeBoard && boardStore.tasks.filter(t => t.boardId === boardStore.activeBoardId).length === 0 && (
<div className="kanban-empty-state kanban-empty-state-inline"> <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> </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", [])) 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: async def test_workspace_bootstrap_work_item_records_manifest_and_reserved_layout(self) -> None:
with _workspace_tempdir() as tmpdir: with _workspace_tempdir() as tmpdir:
workspace = tmpdir / "runtime-root" workspace = tmpdir / "runtime-root"
@@ -276,6 +276,154 @@ class CompanyRuntimeSuspendResumeTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(refreshed_item.metadata.get("dispatch_hold"), "") self.assertEqual(refreshed_item.metadata.get("dispatch_hold"), "")
self.assertEqual(refreshed_item.claimed_by_role_runtime_session_id, "") 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: async def test_second_stop_during_resumed_execution_restores_pending_checkpoint(self) -> None:
store = await self._store() store = await self._store()
_, task = await self._seed_runtime(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: def test_codex_adapter_resolves_launch_binary_before_spawn(self) -> None:
metadata: dict[str, object] = {} 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( cmd = CodexAdapter._resolve_launch_command(
["codex", "exec", "--json", "-"], ["codex", "exec", "--json", "-"],
launch_metadata=metadata, launch_metadata=metadata,
@@ -2523,6 +2524,39 @@ class ExternalAgentMonitoringTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(metadata["configured_binary"], "codex") self.assertEqual(metadata["configured_binary"], "codex")
self.assertEqual(metadata["resolved_binary"], r"C:\Tools\codex.cmd") 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: 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: with patch("opc.layer3_agent.adapters.base.shutil.which", return_value=r"D:\bin\codex.cmd") as which_mock:
CodexAdapter._resolve_launch_command( CodexAdapter._resolve_launch_command(
@@ -2532,6 +2566,40 @@ class ExternalAgentMonitoringTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(which_mock.call_args.kwargs["path"], r"D:\bin") 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: def test_codex_adapter_full_auto_uses_dangerous_bypass_flag(self) -> None:
adapter = CodexAdapter( adapter = CodexAdapter(
config=ExternalAgentConfig(command="codex", approval_mode="full-auto") config=ExternalAgentConfig(command="codex", approval_mode="full-auto")
@@ -5242,10 +5310,11 @@ class ExternalAgentMonitoringTests(unittest.IsolatedAsyncioTestCase):
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
store = _SessionStoreStub() store = _SessionStoreStub()
adapter = _CollabSurfaceScriptAdapter("print('company-mode external run')\n") adapter = _CollabSurfaceScriptAdapter("print('company-mode external run')\n")
broker = ExternalAgentBroker(store, _ApprovalStub())
from opc.layer2_organization.org_engine import OrgEngine from opc.layer2_organization.org_engine import OrgEngine
from opc.core.config import OPCConfig 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( task = Task(
id="collab-env-ceo", id="collab-env-ceo",
title="collab env ceo", title="collab env ceo",
+141
View File
@@ -21,6 +21,7 @@ from __future__ import annotations
import tempfile import tempfile
import unittest import unittest
from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
@@ -37,6 +38,7 @@ from opc.core.models import (
from opc.database.store import OPCStore from opc.database.store import OPCStore
from opc.layer3_agent.adapters.base import ExternalAgentAdapter from opc.layer3_agent.adapters.base import ExternalAgentAdapter
from opc.layer3_agent.external_broker import ExternalAgentBroker 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 ────────────────────────────────────────────── # ── Store helper unit tests ──────────────────────────────────────────────
@@ -97,9 +99,28 @@ class AdapterSessionStateHelperTests(unittest.IsolatedAsyncioTestCase):
await self.store.update_role_session_adapter_state( await self.store.update_role_session_adapter_state(
self.sid, "codex", {"resume_session_id": "thread-abc"}, 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) await self.store.update_role_session_adapter_state(self.sid, "codex", None)
entry = await self.store.get_role_session_adapter_state(self.sid, "codex") 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.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: async def test_missing_session_returns_false(self) -> None:
ok = await self.store.update_role_session_adapter_state( ok = await self.store.update_role_session_adapter_state(
@@ -245,6 +266,49 @@ class BrokerPersistWritesRoleStateTests(unittest.IsolatedAsyncioTestCase):
) )
self.assertIsNone(entry) 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: async def test_consecutive_tasks_overwrite_with_latest_token(self) -> None:
await self._persist_run( await self._persist_run(
task_id="task-1", agent_type="codex", resume_session_id="thread-1", 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["resume_session_id"], "thread-2")
self.assertEqual(entry["last_task_id"], "task-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: async def test_per_agent_isolation_codex_claude_opencode(self) -> None:
# All three external adapters land in the same broker path. Each # All three external adapters land in the same broker path. Each
# gets its own slot under adapter_session_state keyed by agent_type. # gets its own slot under adapter_session_state keyed by agent_type.
@@ -430,6 +522,55 @@ class BrokerRestorePrefersRoleStateTests(unittest.IsolatedAsyncioTestCase):
"codex", "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__": if __name__ == "__main__":
unittest.main() unittest.main()
+26
View File
@@ -164,6 +164,32 @@ def _make_review_task(
class VerdictParseRetryTests(unittest.IsolatedAsyncioTestCase): 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: async def test_unparseable_verdict_spawns_retry_with_hint(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir) root = Path(tmpdir)