@worca/app 1.4.0 → 1.5.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +36 -1
  2. package/docker/.env.example +54 -0
  3. package/docker/compose.clonein.yml +24 -0
  4. package/docker/compose.dev.yml +24 -0
  5. package/docker/compose.egress.yml +47 -0
  6. package/docker/compose.ssh.yml +17 -0
  7. package/docker/compose.teams.yml +25 -0
  8. package/docker/compose.yml +57 -0
  9. package/package.json +4 -1
  10. package/src/cli/container.mjs +227 -0
  11. package/src/cli/models.mjs +5 -4
  12. package/src/cli/schedule.mjs +23 -10
  13. package/src/cli/worca-cc.mjs +62 -4
  14. package/src/core/agent-user.mjs +92 -0
  15. package/src/core/artifacts.mjs +41 -9
  16. package/src/core/ask/clone-deps.mjs +25 -0
  17. package/src/core/ask/clone-proposal.mjs +79 -0
  18. package/src/core/ask/events.mjs +10 -0
  19. package/src/core/ask/mcp-stdio.mjs +4 -2
  20. package/src/core/ask/model-proposal.mjs +14 -3
  21. package/src/core/ask/prompt.mjs +27 -6
  22. package/src/core/ask/schedule-deps.mjs +16 -11
  23. package/src/core/ask/spawn.mjs +8 -3
  24. package/src/core/ask/store.mjs +40 -10
  25. package/src/core/ask/tool-deps.mjs +36 -1
  26. package/src/core/ask/tools.mjs +69 -8
  27. package/src/core/ask/turn.mjs +31 -1
  28. package/src/core/bridge/errors.mjs +50 -2
  29. package/src/core/bridge/provider-ops.mjs +20 -7
  30. package/src/core/bridge/providers/copilot.mjs +51 -5
  31. package/src/core/bridge/translate/common.mjs +144 -0
  32. package/src/core/bridge/translate/request.mjs +16 -73
  33. package/src/core/bridge/translate/responses-request.mjs +185 -0
  34. package/src/core/bridge/translate/responses-stream.mjs +327 -0
  35. package/src/core/bridge/upstream.mjs +37 -10
  36. package/src/core/cf-access.mjs +90 -0
  37. package/src/core/chat/command-router.mjs +17 -12
  38. package/src/core/claude-runner.mjs +26 -3
  39. package/src/core/clone-project.mjs +139 -0
  40. package/src/core/config.mjs +16 -9
  41. package/src/core/db.mjs +60 -5
  42. package/src/core/deployment.mjs +33 -0
  43. package/src/core/diff-comments.mjs +17 -8
  44. package/src/core/git-info.mjs +27 -7
  45. package/src/core/github-app.mjs +107 -0
  46. package/src/core/github-credentials.mjs +93 -0
  47. package/src/core/graph/executor.mjs +22 -4
  48. package/src/core/graph/script-runner.mjs +24 -3
  49. package/src/core/guardrails.mjs +1 -0
  50. package/src/core/identity.mjs +83 -0
  51. package/src/core/metrics/pr-events-workflow.yml +127 -0
  52. package/src/core/metrics/prs.mjs +383 -0
  53. package/src/core/metrics/read.mjs +39 -29
  54. package/src/core/metrics/record.mjs +30 -2
  55. package/src/core/metrics/sync.mjs +35 -4
  56. package/src/core/model-env.mjs +47 -7
  57. package/src/core/model-test.mjs +29 -5
  58. package/src/core/notifications.mjs +36 -14
  59. package/src/core/orchestrator.mjs +10 -4
  60. package/src/core/phases.mjs +2 -0
  61. package/src/core/pipeline-delete.mjs +4 -3
  62. package/src/core/plugin-repo.mjs +7 -3
  63. package/src/core/policy/gate.mjs +9 -5
  64. package/src/core/policy/state.mjs +2 -2
  65. package/src/core/policy/sync.mjs +5 -4
  66. package/src/core/remote-access.mjs +138 -0
  67. package/src/core/run-harness.mjs +71 -9
  68. package/src/core/scheduler.mjs +63 -33
  69. package/src/shared/team-metrics/timeline.mjs +330 -0
  70. package/ui/public/app.js +591 -14
  71. package/ui/public/ask-panel.mjs +65 -3
  72. package/ui/public/bridge-view.mjs +72 -14
  73. package/ui/public/index.html +42 -9
  74. package/ui/public/schedules-view.mjs +21 -2
  75. package/ui/public/session-guard.mjs +77 -0
  76. package/ui/public/style.css +171 -1
  77. package/ui/public/team-metrics-timeline.mjs +480 -0
  78. package/ui/public/team-metrics-view.mjs +10 -2
  79. package/ui/public/team-policy-view.mjs +6 -1
  80. package/ui/server.mjs +454 -104
@@ -319,6 +319,20 @@ export function createAskTools(deps) {
319
319
  catch { return null; }
320
320
  };
321
321
 
322
+ // The person signed in to this chat on a shared sign-in (identity.mjs isSharedIdentity), else null.
323
+ const viewer = () => (typeof deps.viewer === 'string' && deps.viewer ? deps.viewer : null);
324
+ // A startedBy filter: lowercased; "me" resolves to the viewer, and is refused without a shared sign-in
325
+ // (on a local or one-person worca runs are not told apart by person).
326
+ const personFilter = (v, tool) => {
327
+ const raw = str(v);
328
+ if (!raw) return '';
329
+ if (raw.toLowerCase() === 'me') {
330
+ if (!viewer()) throw new AskToolError(`${tool}: "me" needs a per-person sign-in; this worca does not tell people apart — name the person, or leave startedBy out`);
331
+ return viewer().toLowerCase();
332
+ }
333
+ return raw.replace(/ \(this machine\)$/i, '').toLowerCase();
334
+ };
335
+
322
336
  const defs = [
323
337
  { name: 'list_projects',
324
338
  description: 'List the registered projects (key, name, path) and workspaces (id, name, member project keys). Use the key / id in the other tools.',
@@ -327,11 +341,15 @@ export function createAskTools(deps) {
327
341
  description: 'List the saved workflows with their ordered step groups (parallel agent nodes share a group) and feedback loops. Pick one by name, domain and steps.',
328
342
  inputSchema: SCHEMA.obj({}) },
329
343
  { name: 'list_runs',
330
- description: 'Find past runs, newest first. Optional filters: projectKey OR workspaceId, status (e.g. done, running, error, stopped), query (title substring). limit defaults to 20, max 100.',
344
+ description: 'Find past runs, newest first. Optional filters: projectKey OR workspaceId, status (e.g. done, running, error, stopped), query (title substring), startedBy (the person who started the run, exactly as list_people names them, case-insensitive; "me" = the person signed in to this chat, only on a shared sign-in). Each row carries startedBy (a person, "local" for this machine, null before attribution). limit defaults to 20, max 100.',
331
345
  inputSchema: SCHEMA.obj({ projectKey: SCHEMA.s('project key from list_projects'), workspaceId: SCHEMA.s('workspace id from list_projects'),
332
- status: SCHEMA.s('run status to match'), limit: SCHEMA.i('max results (1-100)', 1, L.listRunsMaxLimit), query: SCHEMA.s('case-insensitive title substring') }) },
346
+ status: SCHEMA.s('run status to match'), limit: SCHEMA.i('max results (1-100)', 1, L.listRunsMaxLimit), query: SCHEMA.s('case-insensitive title substring'),
347
+ startedBy: SCHEMA.s('who started the run (a name from list_people, or "me")') }) },
348
+ { name: 'list_people',
349
+ description: 'Who has started runs on this worca: one row per person (the name recorded when they started a run — a verified sign-in email, a trusted-header name, or the operator\'s declared name; "local" = runs started on this machine with no identity) with runs, lastRunAt and totalCostUsd, most active first. Optional scope: projectKey OR workspaceId. Runs from before attribution existed have no person and are not counted. Read-only; this machine\'s runs only (team-wide people are in get_team_metrics actor breakdowns).',
350
+ inputSchema: SCHEMA.obj({ projectKey: SCHEMA.s('project key from list_projects'), workspaceId: SCHEMA.s('workspace id from list_projects') }) },
333
351
  { name: 'get_run',
334
- description: 'Read one run: its metadata and the user\'s original prompt. Give projectKey or workspaceId when known; without them the user-pinned scope (when the chat has one) is tried first, then the id is searched everywhere.',
352
+ description: 'Read one run: its metadata, the user\'s original prompt, startedBy (the person who started it), `scheduled` when a schedule started it, and `actions` — the people who acted on it (paused, resumed, stopped, answered its questions, continued past a cost cap, opened its PR, archived it) with when. Give projectKey or workspaceId when known; without them the user-pinned scope (when the chat has one) is tried first, then the id is searched everywhere.',
335
353
  inputSchema: SCHEMA.obj({ id: SCHEMA.s('run id (8 hex)'), projectKey: SCHEMA.s('scope to a project'), workspaceId: SCHEMA.s('scope to a workspace') }, ['id']) },
336
354
  { name: 'get_run_diff',
337
355
  description: 'Read the unified diff of a run, paged by byte offset (use nextOffset until truncated is false). Optional path = one file only. files[] lists every file with added/removed counts; credential files are omitted.',
@@ -575,7 +593,7 @@ export function createAskTools(deps) {
575
593
  // tool-list pin stays byte-identical. Every change is a card the user applies.
576
594
  ...(deps.models ? [
577
595
  { name: 'list_models',
578
- description: 'The model catalog every picker, workflow node and Ask chat draws from: id, label, source (built-in | user | plugin | team policy), editable (a user entry — the only kind propose_model_change edits or removes), efforts, connection ("default" = the claude CLI\'s own login; "env" = the entry\'s ANTHROPIC_BASE_URL/… env points the CLI at an endpoint that speaks the Messages API; "provider" = worca\'s bridge forwards to a provider), and for a bridged model its provider, upstreamApi (anthropic passes through; openai-chat is translated), upstreamModel, capabilities (maxPromptTokens / maxOutputTokens …) and ready / notReady. A user entry adds its own config under entry: env (credential values masked; ${VAR} references readable), upstream (baseUrl, apiKey masked or ${VAR}, headers, capabilities) and cost. Read-only.',
596
+ description: 'The model catalog every picker, workflow node and Ask chat draws from: id, label, source (built-in | user | plugin | team policy), editable (a user entry — the only kind propose_model_change edits or removes), efforts, connection ("default" = the claude CLI\'s own login; "env" = the entry\'s ANTHROPIC_BASE_URL/… env points the CLI at an endpoint that speaks the Messages API; "provider" = worca\'s bridge forwards to a provider), and for a bridged model its provider, upstreamApi (anthropic passes through; openai-chat and openai-responses are translated), upstreamModel, capabilities (maxPromptTokens / maxOutputTokens / reasoningEfforts …) and ready / notReady. A user entry adds its own config under entry: env (credential values masked; ${VAR} references readable), upstream (baseUrl, apiKey masked or ${VAR}, headers, capabilities) and cost. Read-only.',
579
597
  inputSchema: SCHEMA.obj({}) },
580
598
  { name: 'get_providers',
581
599
  description: 'The providers that bridged models share (Settings › Providers): copilot (connected, login, accountType, termsCurrent — the notice acknowledgement, maxConcurrent), openai and anthropic (baseUrl, keySet, keySource "env" | "stored", keyRef when it is a ${VAR} reference, keyOptional — a local OpenAI-compatible endpoint needs no key, configured — the key resolves now, maxConcurrent). Never a key. Read-only.',
@@ -584,13 +602,13 @@ export function createAskTools(deps) {
584
602
  description: 'Check one provider now: openai / anthropic GET the endpoint\'s models list with the configured key (reachability + auth, and how many models it lists); copilot exchanges the sign-in for a Copilot token. Returns {ok:true, models?} or {ok:false, message}. The endpoint is contacted.',
585
603
  inputSchema: SCHEMA.obj({ provider: SCHEMA.s('copilot | openai | anthropic') }, ['provider']) },
586
604
  { name: 'list_copilot_models',
587
- description: 'The chat models GitHub Copilot offers the signed-in account, for import: id (pass it to propose_model_change kind "import_copilot"), name, vendor, context and output limits, whether it is enabled in the account\'s Copilot settings, and inCatalog / catalogId (imports land as copilot-<id>). Fails when Copilot is not signed in. GitHub is contacted.',
605
+ description: 'The chat models GitHub Copilot offers the signed-in account, for import: id (pass it to propose_model_change kind "import_copilot"), name, vendor, context and output limits, whether it is enabled in the account\'s Copilot settings, and inCatalog / catalogId (imports land as copilot-<id>), and api — the wire protocol an import uses (anthropic, openai-chat or openai-responses). Fails when Copilot is not signed in. GitHub is contacted.',
588
606
  inputSchema: SCHEMA.obj({}) },
589
607
  { name: 'list_endpoint_models',
590
608
  description: 'Ask an OpenAI-compatible endpoint what it serves — llama.cpp\'s llama-server, Ollama, LM Studio, vLLM or a gateway — so a model can be added without typing ids or limits. baseUrl defaults to the openai provider\'s (get_providers); pass one to browse another server without saving it first. Returns server (llama.cpp | ollama | lmstudio | vllm | openai-compatible), baseUrl, warnings, and per model: id (pass it to propose_model_change kind "import_endpoint"), catalogId, inCatalog, kind (llm | embedding), servedContext — the window ONE request really gets, the only number a prompt limit may come from — trainedContext (what the model supports, often far larger), toolCalls, vision, reasoning, loaded, and importable / blocked. Read the warnings out: Ollama serves 4096 tokens by default whatever the model supports, and llama-server splits its -c across --parallel slots. The endpoint is contacted.',
591
609
  inputSchema: SCHEMA.obj({ baseUrl: SCHEMA.s('the endpoint\'s OpenAI base URL, e.g. http://127.0.0.1:11434/v1 (default: the openai provider\'s)') }) },
592
610
  { name: 'propose_model_change',
593
- description: 'Propose a model catalog or provider change for the user to confirm — it never changes anything itself; the user sees a card with the before → after and applies or declines it. kind: "add_model" (model: {id, label?, efforts?, env?, cost?, upstream?} — a Messages-API endpoint by env: {ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN:"${VAR}", …}; through a provider by upstream: {provider: openai | anthropic | copilot, api: anthropic | openai-chat, model: <the id the endpoint expects>, baseUrl?, apiKey?: "${VAR}", headers?, capabilities?: {maxPromptTokens, maxOutputTokens, …}}; cost: {free:true} or {perMtok:{input, output, …}}), "edit_model" (id + model: the fields to change — env merges per key, null deletes a key; upstream merges into the current block, a null field removes it, capabilities merge per key; upstream:null drops the bridge), "remove_model" (id — workflow nodes that name it fall back to the default model), "provider" (provider + set: {baseUrl?, apiKey?: "${VAR}", maxConcurrent?, accountType?: individual | business | enterprise (copilot)}; null or "" clears a field), "import_copilot" (ids from list_copilot_models), "import_endpoint" (ids from list_endpoint_models, with its baseUrl when you passed one — each becomes a free, keyless entry carrying the window the server reports). Credentials are ${VAR} references to variables in worca\'s environment, never the value: a literal key is refused — the user pastes one in Settings › Providers. The Copilot sign-in and its notice are the user\'s, on the Providers card (Settings › Providers). Returns {ok:true, card} (card.warnings: what will still stop the model working) or {ok:false, errors} to fix and retry. Never claim a change was applied — the card says so when it happens.',
611
+ description: 'Propose a model catalog or provider change for the user to confirm — it never changes anything itself; the user sees a card with the before → after and applies or declines it. kind: "add_model" (model: {id, label?, efforts?, env?, cost?, upstream?} — a Messages-API endpoint by env: {ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN:"${VAR}", …}; through a provider by upstream: {provider: openai | anthropic | copilot, api: anthropic | openai-chat | openai-responses, model: <the id the endpoint expects>, baseUrl?, apiKey?: "${VAR}", headers?, capabilities?: {maxPromptTokens, maxOutputTokens, reasoning, reasoningEfforts?: the effort levels the model lists, …}}; cost: {free:true} or {perMtok:{input, output, …}}), "edit_model" (id + model: the fields to change — env merges per key, null deletes a key; upstream merges into the current block, a null field removes it, capabilities merge per key; upstream:null drops the bridge), "remove_model" (id — workflow nodes that name it fall back to the default model), "provider" (provider + set: {baseUrl?, apiKey?: "${VAR}", maxConcurrent?, accountType?: individual | business | enterprise (copilot)}; null or "" clears a field), "import_copilot" (ids from list_copilot_models), "import_endpoint" (ids from list_endpoint_models, with its baseUrl when you passed one — each becomes a free, keyless entry carrying the window the server reports). Credentials are ${VAR} references to variables in worca\'s environment, never the value: a literal key is refused — the user pastes one in Settings › Providers. The Copilot sign-in and its notice are the user\'s, on the Providers card (Settings › Providers). Returns {ok:true, card} (card.warnings: what will still stop the model working) or {ok:false, errors} to fix and retry. Never claim a change was applied — the card says so when it happens.',
594
612
  inputSchema: SCHEMA.obj({ kind: SCHEMA.s('add_model | edit_model | remove_model | provider | import_copilot'),
595
613
  id: SCHEMA.s('edit_model / remove_model: the catalog model id'),
596
614
  model: { type: 'object', description: 'add_model: the entry; edit_model: the fields to change', additionalProperties: true },
@@ -600,6 +618,14 @@ export function createAskTools(deps) {
600
618
  baseUrl: SCHEMA.s('import_endpoint: the endpoint listed, when it is not the provider\'s own'),
601
619
  note: SCHEMA.s('one line shown on the card: why this change (≤ 200 chars)') }, ['kind']) },
602
620
  ] : []),
621
+ ...(deps.clones ? [
622
+ { name: 'propose_clone_project',
623
+ description: 'Propose adding a project by cloning a repository into worca\'s projects folder, for the user to confirm — it never clones anything itself; the user sees a card with the URL, branch, target folder and how GitHub is reached, and applies or declines it. url: an https:// repository URL (https://github.com/owner/repo), no credentials in it. branch: optional (default: the repository\'s default branch). name: optional folder and project name (default: the repository name). Returns {ok:true, card} or {ok:false, errors} — fix the input and call again.',
624
+ inputSchema: SCHEMA.obj({ url: SCHEMA.s('https:// repository URL, e.g. https://github.com/acme/api'),
625
+ branch: SCHEMA.s('branch to check out (default: the repository default)'),
626
+ name: SCHEMA.s('folder and project name (default: the repository name)'),
627
+ note: SCHEMA.s('one line shown on the card: why (≤ 200 chars)') }, ['url']) },
628
+ ] : []),
603
629
  ];
604
630
 
605
631
  const EMPTY_DIFF = () => ({ available: false, files: [], text: '', truncated: false, totalBytes: 0, nextOffset: 0 });
@@ -673,8 +699,10 @@ export function createAskTools(deps) {
673
699
  prompt: row.prompt == null ? null : deps.redact(row.prompt), // run prompts are untrusted text (spec §6.3/§6.6)
674
700
  totalCostUsd: deps.totalsFor(row).cost,
675
701
  archived: !!row.archived_at,
702
+ // The person who started it (identity.mjs; 'local' on a local install, null before attribution).
703
+ startedBy: row.started_by ?? null,
676
704
  // Started by a schedule (docs/scheduled-runs.md): only then, so every other run keeps its shape.
677
- ...(row.scheduled_for || row.schedule_id ? { startedBy: { scheduledFor: row.scheduled_for ?? null, scheduleId: row.schedule_id ?? null } } : {}),
705
+ ...(row.scheduled_for || row.schedule_id ? { scheduled: { scheduledFor: row.scheduled_for ?? null, scheduleId: row.schedule_id ?? null } } : {}),
678
706
  };
679
707
  }
680
708
 
@@ -1093,7 +1121,17 @@ export function createAskTools(deps) {
1093
1121
  overlap: s.overlap, maxFailures: s.maxFailures, failureStreak: s.failureStreak, ifMissed: s.ifMissed, graceMin: s.graceMin,
1094
1122
  runsCount: s.runsCount, lastResult: s.lastResult, request: shapeRequest(s.summary),
1095
1123
  ...(s.askCardId ? { askCardId: s.askCardId } : {}),
1124
+ ...peopleOf(s),
1096
1125
  });
1126
+ // Who made / last changed a series or scheduled run (identity.mjs actors); absent when unknown, and
1127
+ // 'local' is not a person, so it is left out too.
1128
+ const peopleOf = (x) => {
1129
+ const person = (v) => (typeof v === 'string' && v && v !== 'local' ? v : null);
1130
+ const out = {};
1131
+ if (person(x.createdBy)) out.createdBy = person(x.createdBy);
1132
+ if (person(x.updatedBy)) out.updatedBy = person(x.updatedBy);
1133
+ return out;
1134
+ };
1097
1135
  const shapeTicket = (t) => {
1098
1136
  // Run chains (spec D11): a waiting after-ticket's run_at is the year-9999 sentinel — never a time to show.
1099
1137
  // The model sees what it waits for instead; `after.id` is a run id (get_run) or a scheduled run id (get_schedule).
@@ -1104,6 +1142,7 @@ export function createAskTools(deps) {
1104
1142
  after: t.after ? { kind: t.after.kind, id: t.after.id, policy: t.after.policy } : null, sourceFromPrevious: !!t.sourceFromPrevious,
1105
1143
  failReason: t.failReason ? deps.redact(t.failReason) : null, pipelineId: t.pipelineId, attempts: t.attempts,
1106
1144
  ifMissed: t.ifMissed, graceMin: t.graceMin, heldByTerminal: !!t.ownerPid, request: shapeRequest(t.summary),
1145
+ ...peopleOf(t),
1107
1146
  };
1108
1147
  };
1109
1148
  const shapeNotice = (n) => ({
@@ -1252,6 +1291,7 @@ export function createAskTools(deps) {
1252
1291
  const status = str(input.status).toLowerCase();
1253
1292
  const query = str(input.query).toLowerCase();
1254
1293
  const limit = clampInt(input.limit, 1, L.listRunsMaxLimit, L.listRunsDefaultLimit);
1294
+ const startedBy = personFilter(input.startedBy, 'list_runs');
1255
1295
  // Unkeyed: the newest runsScanLimit rows are enough. Keyed: scan everything — a
1256
1296
  // project's runs may all be older than the 200 globally newest (lite = one SQL
1257
1297
  // + one readdir per store key, no git).
@@ -1261,6 +1301,7 @@ export function createAskTools(deps) {
1261
1301
  if (wantKey && e.projectKey !== wantKey) continue;
1262
1302
  if (status && String(e.status ?? '').toLowerCase() !== status) continue;
1263
1303
  if (query && !String(e.title ?? '').toLowerCase().includes(query)) continue;
1304
+ if (startedBy && String(e.startedBy ?? '').toLowerCase() !== startedBy) continue;
1264
1305
  const isWs = e.target === 'workspace' || String(e.projectKey).startsWith('workspaces/');
1265
1306
  out.push({
1266
1307
  id: e.id, title: deps.redact(e.title ?? e.id), target: isWs ? 'workspace' : 'project',
@@ -1271,6 +1312,7 @@ export function createAskTools(deps) {
1271
1312
  updatedAt: e.mtime ? new Date(e.mtime).toISOString() : null,
1272
1313
  branch: e.branch ?? null, sourceBranch: e.sourceBranch ?? null, guardrailsId: e.guardrailsId ?? null,
1273
1314
  totalCostUsd: e.totalCostUsd ?? null,
1315
+ startedBy: e.startedBy ?? null,
1274
1316
  });
1275
1317
  if (out.length >= limit) break;
1276
1318
  }
@@ -1283,7 +1325,22 @@ export function createAskTools(deps) {
1283
1325
  // by the injected dep; a row-only bundle answers null and the shape stays byte-identical).
1284
1326
  const memory = typeof deps.readRunMemory === 'function' ? await deps.readRunMemory(row) : null;
1285
1327
  const policy = runPolicy(row);
1286
- return { ...run, hasDiff: !run.archived && await deps.hasDiffPatch(row), ...(memory ? { memory } : {}), ...(policy ? { policy } : {}) };
1328
+ // Who acted on the run (pipeline_events.actor): only when anyone did, so other runs keep their shape.
1329
+ const actions = typeof deps.readRunActions === 'function' ? await deps.readRunActions(row) : [];
1330
+ return { ...run, hasDiff: !run.archived && await deps.hasDiffPatch(row), ...(memory ? { memory } : {}), ...(policy ? { policy } : {}),
1331
+ ...(actions.length ? { actions: actions.map((a) => ({ at: a.at, by: a.by, what: deps.redact(a.what) })) } : {}) };
1332
+ },
1333
+ async list_people(input) {
1334
+ const projectKey = str(input.projectKey);
1335
+ const workspaceId = str(input.workspaceId);
1336
+ if (projectKey && workspaceId) throw new AskToolError('list_people: give projectKey OR workspaceId, not both');
1337
+ if (typeof deps.listPeople !== 'function') return [];
1338
+ const rows = await deps.listPeople(workspaceId ? { workspaceKey: workspaceId } : projectKey ? { projectKey } : {});
1339
+ return rows.map((r) => ({
1340
+ name: r.name === 'local' ? 'local (this machine)' : r.name,
1341
+ ...(viewer() && r.name.toLowerCase() === viewer().toLowerCase() ? { you: true } : {}),
1342
+ runs: r.runs, lastRunAt: r.lastRunAt ?? null, totalCostUsd: Number(r.totalCostUsd || 0),
1343
+ }));
1287
1344
  },
1288
1345
  // Read-only by contract: the parent process (ui/server.mjs askTrackRun, via the turn's onTrackRun hook) does the
1289
1346
  // linking and the following. A live run id lives only in the server's runs Map, so the child passes it through.
@@ -1881,6 +1938,10 @@ export function createAskTools(deps) {
1881
1938
  catch (err) { throw new AskToolError(`list_copilot_models: ${err && err.message ? err.message : err}`); }
1882
1939
  },
1883
1940
  async propose_model_change(input) { return modelsOf('propose_model_change').validateChange(input); },
1941
+ async propose_clone_project(input) {
1942
+ if (!deps.clones) throw new AskToolError('propose_clone_project: cloning is unavailable');
1943
+ return deps.clones.validateChange(input);
1944
+ },
1884
1945
  async save_script(input) {
1885
1946
  const s = scriptWriterOf('save_script');
1886
1947
  // The model's OWN text, on its way to disk: never redacted here (a redaction marker
@@ -27,6 +27,7 @@ import { validateProposal } from './proposal.mjs';
27
27
  import { validateMetricsChange } from './metrics-deps.mjs';
28
28
  import { validatePolicyChange } from './policy-deps.mjs';
29
29
  import { validateModelChange } from './model-deps.mjs';
30
+ import { validateCloneProposal } from './clone-deps.mjs';
30
31
  import { validateScheduleChange } from './schedule-deps.mjs';
31
32
  import { lookupTask } from './source-deps.mjs';
32
33
  import { effectiveTimeZone } from './schedule-spec.mjs';
@@ -53,10 +54,13 @@ class AskTurn extends EventEmitter {
53
54
  pinnedScope = null,
54
55
  memoryProject = null,
55
56
  timeZone = null,
57
+ reader = null,
56
58
  deps = {},
57
59
  } = {}) {
58
60
  super();
59
61
  this.threadId = threadId;
62
+ // A shared sign-in's name (identity.mjs): the MCP child's per-person reads (notifications).
63
+ this.reader = typeof reader === 'string' && reader ? reader : null;
60
64
  this.assistantMessageId = assistantMessageId;
61
65
  this.userMessageId = userMessageId;
62
66
  this.prompt = prompt;
@@ -93,6 +97,7 @@ class AskTurn extends EventEmitter {
93
97
  validatePolicyChange: deps.validatePolicyChange ?? validatePolicyChange,
94
98
  validateScheduleChange: deps.validateScheduleChange ?? validateScheduleChange,
95
99
  validateModelChange: deps.validateModelChange ?? validateModelChange,
100
+ validateCloneProposal: deps.validateCloneProposal ?? validateCloneProposal,
96
101
  scheduleDefaults: deps.scheduleDefaults ?? scheduleDefaults,
97
102
  // A proposed plugin task is looked up here, once: it must exist, and the card shows its title.
98
103
  lookupTask: deps.lookupTask === undefined ? lookupTask : deps.lookupTask,
@@ -344,6 +349,30 @@ class AskTurn extends EventEmitter {
344
349
  this._persistBlocks();
345
350
  }
346
351
 
352
+ /**
353
+ * propose_clone_project RESULT: the model card's split — the child validated for the model, the parent
354
+ * re-validates the same INPUT (and adds how GitHub is reached) and mints the card. A child {ok:false}
355
+ * already reached the model as text: no card, no notice.
356
+ */
357
+ async _onCloneProposal(input, text, isError) {
358
+ if (isError) return;
359
+ let out = null;
360
+ try { out = JSON.parse(text); } catch { out = null; }
361
+ if (!out || out.ok !== true) return;
362
+ const d = this.deps;
363
+ try {
364
+ const r = await d.validateCloneProposal(input && typeof input === 'object' ? input : {});
365
+ if (r && r.ok) this.reducer.addBlock({ kind: 'card', id: d.newAskId('card'), state: 'proposed', card: r.card });
366
+ else {
367
+ const errors = (r && Array.isArray(r.errors) && r.errors.length) ? r.errors : ['invalid proposal'];
368
+ this.reducer.addBlock({ kind: 'notice', text: `Clone rejected: ${errors.join('; ')}` });
369
+ }
370
+ } catch (err) {
371
+ this.reducer.addBlock({ kind: 'notice', text: `Clone rejected: ${err?.message || err}` });
372
+ }
373
+ this._persistBlocks();
374
+ }
375
+
347
376
  /** The card exists from the tool_use on (spec §8.2, PD7): a building block with the four-step trace, persisted. */
348
377
  _onWorkflowStart(toolUseId, input) {
349
378
  const d = this.deps;
@@ -431,6 +460,7 @@ class AskTurn extends EventEmitter {
431
460
  onPolicyProposal: ({ input, text, isError }) => this._onPolicyProposal(input, text, isError),
432
461
  onScheduleProposal: ({ input, text, isError }) => this._onScheduleProposal(input, text, isError),
433
462
  onModelProposal: ({ input, text, isError }) => this._onModelProposal(input, text, isError),
463
+ onCloneProposal: ({ input, text, isError }) => this._onCloneProposal(input, text, isError),
434
464
  // pause / resume / skip / mark-read in the child → the server's schedules-changed frames.
435
465
  onScheduleMutation: (e) => { try { this.deps.onScheduleMutation(e); } catch { /* a broken sink never breaks the turn */ } },
436
466
  // The MCP child cannot broadcast; the parent turns its comment writes into
@@ -583,7 +613,7 @@ class AskTurn extends EventEmitter {
583
613
  mcpConfigPath = join(scratchDir, `mcp-${this.assistantMessageId}.json`);
584
614
  await d.fs.writeFile(
585
615
  mcpConfigPath,
586
- JSON.stringify(d.buildMcpConfig({ homeBase, threadId: this.threadId, serverPath: d.serverPath }), null, 2),
616
+ JSON.stringify(d.buildMcpConfig({ homeBase, threadId: this.threadId, serverPath: d.serverPath, ...(this.reader ? { reader: this.reader } : {}) }), null, 2),
587
617
  'utf8',
588
618
  );
589
619
  // One 30-minute budget for the whole turn, retry included. The timedOut
@@ -11,7 +11,7 @@ export const PAYLOAD_CEILING_BYTES = 2_500_000;
11
11
  /** The phrase Claude Code matches on to trigger its own compaction. */
12
12
  export const PROMPT_TOO_LONG = 'prompt is too long';
13
13
 
14
- const CONTEXT_RE = /context.?length|too long|maximum context|token limit|exceeds? .*tokens|too many tokens|request too large|payload too large/i;
14
+ const CONTEXT_RE = /context.?length|too long|maximum context|token limit|exceeds? .*tokens|too many tokens|request too large|payload too large|exceeds the context window/i;
15
15
 
16
16
  /** Envelope for a status/type/message triple. */
17
17
  export function anthropicError(status, type, message) {
@@ -30,6 +30,51 @@ export function upstreamMessage(text) {
30
30
  return String(text).slice(0, 500);
31
31
  }
32
32
 
33
+ /** The machine-readable code in an upstream error body ('' when there is none). */
34
+ export function upstreamCode(text) {
35
+ if (!text) return '';
36
+ try {
37
+ const j = JSON.parse(text);
38
+ const e = j && (j.error || j);
39
+ return e && typeof e === 'object' && typeof e.code === 'string' ? e.code : '';
40
+ } catch { return ''; }
41
+ }
42
+
43
+ /** An upstream saying it serves this model through the other OpenAI protocol (Copilot: unsupported_api_for_model). */
44
+ const UNSUPPORTED_API_RE = /not accessible via the \/(chat\/completions|responses) endpoint|does not support (the )?Responses API/i;
45
+
46
+ /** The fix that refusal needs, per provider. */
47
+ export function unsupportedApiFix(provider) {
48
+ return provider === 'copilot'
49
+ ? 'this model needs a different API: re-import it (Settings › Models › Import models…) or change its API in the model editor'
50
+ : 'this model needs a different API: change its API in the model editor';
51
+ }
52
+
53
+ /** Error codes for a prompt past the model's context window: OpenAI's, and Copilot's own limit check. */
54
+ const CONTEXT_CODES = new Set(['context_length_exceeded', 'model_max_prompt_tokens_exceeded']);
55
+
56
+ /** Whether an upstream error — by its code or its message — says the prompt overflowed the context window. */
57
+ export function isContextOverflow(code, message) {
58
+ return CONTEXT_CODES.has(String(code || '')) || CONTEXT_RE.test(String(message || ''));
59
+ }
60
+
61
+ /** A code or wording that names another failure: a rate limit, a quota, a server error, a timeout. */
62
+ const OTHER_FAILURE_RE = /rate.?limit|quota|server_error|overloaded|unavailable|timed? ?out|timeout|per min(ute)?\b|\bTPM\b/i;
63
+
64
+ /**
65
+ * isContextOverflow for a failure reported inside an HTTP 200 — a Responses
66
+ * stream's response.failed / error, or a buffered body with status "failed".
67
+ * No status narrows it to a 400 there, so a code or wording that names another
68
+ * failure outranks CONTEXT_RE's broad phrases ("too long", "request too
69
+ * large"): a rate limit or a timeout must never make the CLI compact.
70
+ */
71
+ export function isFailedResponseOverflow(code, message) {
72
+ const c = String(code || '');
73
+ const m = String(message || '');
74
+ if (CONTEXT_CODES.has(c)) return true;
75
+ return !OTHER_FAILURE_RE.test(c) && !OTHER_FAILURE_RE.test(m) && CONTEXT_RE.test(m);
76
+ }
77
+
33
78
  /**
34
79
  * Map an upstream HTTP failure to the envelope.
35
80
  * @param {number} status upstream status
@@ -47,9 +92,12 @@ export function mapUpstreamError(status, text, { provider = 'upstream', retryAft
47
92
  if (retryAfter) e.headers = { 'retry-after': String(retryAfter) };
48
93
  return e;
49
94
  }
50
- if (status === 413 || (status === 400 && CONTEXT_RE.test(msg))) {
95
+ if (status === 413 || (status === 400 && isContextOverflow(upstreamCode(text), msg))) {
51
96
  return anthropicError(400, 'invalid_request_error', PROMPT_TOO_LONG);
52
97
  }
98
+ if (status >= 400 && status < 500 && (upstreamCode(text) === 'unsupported_api_for_model' || UNSUPPORTED_API_RE.test(msg))) {
99
+ return anthropicError(400, 'invalid_request_error', `${who}: ${msg || 'the model is not served through this API'} — ${unsupportedApiFix(who)}`);
100
+ }
53
101
  if (status === 404) {
54
102
  return anthropicError(400, 'invalid_request_error', `${who}: model or endpoint not found (404)${msg ? ` — ${msg}` : ''}`);
55
103
  }
@@ -11,10 +11,10 @@ import {
11
11
  copilotTermsAcknowledged, acknowledgeCopilotTerms, clearCopilotSignIn,
12
12
  listGlobalModels, addGlobalModel, updateGlobalModel,
13
13
  } from '../settings.mjs';
14
- import { modelEnvRef, maskModelEnvValue, COPILOT_TERMS_VERSION, UPSTREAM_PROVIDERS, isUpstreamBaseUrl } from '../model-env.mjs';
14
+ import { modelEnvRef, maskModelEnvValue, COPILOT_TERMS_VERSION, UPSTREAM_PROVIDERS, isUpstreamBaseUrl, EFFORTS } from '../model-env.mjs';
15
15
  import {
16
16
  startDeviceFlow, pollDeviceFlow, githubLogin, copilotToken, invalidateCopilotToken,
17
- listCopilotModels, copilotUsage, catalogEntryForCopilotModel,
17
+ listCopilotModels, copilotUsage, catalogEntryForCopilotModel, copilotApiFor,
18
18
  } from './providers/copilot.mjs';
19
19
  import { keyOptional } from './registry.mjs';
20
20
  import { listEndpointModels, catalogEntryForEndpointModel, importableModel } from './providers/endpoint.mjs';
@@ -139,14 +139,15 @@ export async function copilotModelsForImport({ fetch: f } = {}) {
139
139
  if (!token) throw Object.assign(new Error('not signed in to GitHub Copilot'), { code: 'NOT_SIGNED_IN' });
140
140
  const list = await listCopilotModels(token, { accountType: c.accountType, fetch: f });
141
141
  const have = new Set(listGlobalModels().map((m) => m.id.toLowerCase()));
142
- return list.map((m) => ({ ...m, catalogId: `copilot-${m.id}`, inCatalog: have.has(`copilot-${m.id}`.toLowerCase()) }))
142
+ return list.map((m) => ({ ...m, api: copilotApiFor(m), catalogId: `copilot-${m.id}`, inCatalog: have.has(`copilot-${m.id}`.toLowerCase()) }))
143
143
  .sort((a, b) => (Number(b.pickerEnabled) - Number(a.pickerEnabled)) || a.name.localeCompare(b.name));
144
144
  }
145
145
 
146
146
  /**
147
147
  * Import Copilot models into the catalog (§8.4). A new id gets the full
148
- * import shape; an existing `copilot-*` id only has its capabilities (and api,
149
- * should the vendor route change) refreshed — a user-edited label, efforts or
148
+ * import shape; an existing `copilot-*` id has its api, upstream model and
149
+ * capabilities refreshed, and its efforts widened only when they are the old
150
+ * importer's automatic medium-only default — a user-edited label, efforts or
150
151
  * pricing is never overwritten.
151
152
  * @param {string[]} ids Copilot model ids (not catalog ids)
152
153
  * @returns {Promise<{created:string[], updated:string[], skipped:string[]}>}
@@ -164,7 +165,19 @@ export async function importCopilotModels(ids, { fetch: f } = {}) {
164
165
  if (m.inCatalog) {
165
166
  const current = listGlobalModels().find((x) => x.id.toLowerCase() === entry.id.toLowerCase());
166
167
  if (!current || !current.upstream || current.upstream.provider !== 'copilot') { skipped.push(id); continue; }
167
- await updateGlobalModel(current.id, { upstream: { ...current.upstream, api: entry.upstream.api, model: entry.upstream.model, capabilities: entry.upstream.capabilities } });
168
+ const patch = { upstream: { ...current.upstream, api: entry.upstream.api, model: entry.upstream.model, capabilities: entry.upstream.capabilities } };
169
+ // Before Copilot listed effort levels, a translated model the id regex missed
170
+ // was stored non-reasoning and trimmed to medium. Widen exactly that automatic
171
+ // default — never a list the user chose: a reasoning entry pinned to medium
172
+ // stays, and an Anthropic entry was never trimmed, so its medium is the user's.
173
+ const autoMediumOnly = current.upstream.api !== 'anthropic' && entry.upstream.api !== 'anthropic'
174
+ && Array.isArray(current.efforts) && current.efforts.length === 1 && current.efforts[0] === 'medium'
175
+ && !(current.upstream.capabilities && current.upstream.capabilities.reasoning === true);
176
+ if (autoMediumOnly) {
177
+ const next = entry.efforts === undefined ? [...EFFORTS] : entry.efforts;
178
+ if (!(next.length === 1 && next[0] === 'medium')) patch.efforts = next;
179
+ }
180
+ await updateGlobalModel(current.id, patch);
168
181
  updated.push(current.id);
169
182
  } else {
170
183
  await addGlobalModel(entry);
@@ -206,7 +219,7 @@ export async function endpointModelsForImport({ baseUrl, fetch: f } = {}) {
206
219
 
207
220
  /**
208
221
  * Import endpoint models into the catalog. A new id gets the full entry; an existing one keeps the
209
- * label, efforts and pricing you edited and only has its upstream refreshed — the Copilot rule.
222
+ * label, efforts and pricing you edited and only has its upstream refreshed.
210
223
  * @param {string[]} ids model ids as the endpoint reports them (not catalog ids)
211
224
  * @returns {Promise<{created:string[], updated:string[], skipped:Array<{id:string, why:string}>, server:string, baseUrl:string}>}
212
225
  */
@@ -12,6 +12,8 @@
12
12
  // Every network call takes an injectable `fetch` so the whole module is
13
13
  // testable against a stub without touching github.com.
14
14
 
15
+ import { EFFORTS, REASONING_EFFORT_LEVELS } from '../../model-env.mjs';
16
+
15
17
  export const GITHUB_CLIENT_ID = 'Iv1.b507a08c87ecfe98';
16
18
  export const GITHUB_SCOPES = 'read:user';
17
19
  export const GITHUB_BASE = 'https://github.com';
@@ -84,7 +86,11 @@ export function bodyHasImage(body) {
84
86
  /** 'agent' when the last message continues a tool loop, else 'user' (§7.1). */
85
87
  export function requestInitiator(body) {
86
88
  const msgs = body && Array.isArray(body.messages) ? body.messages : [];
87
- const last = msgs[msgs.length - 1];
89
+ // The CLI appends its mid-conversation system reminders AFTER the turn they
90
+ // ride on (…, user[tool_result], system): the turn is the last message before them.
91
+ let i = msgs.length - 1;
92
+ while (i >= 0 && msgs[i] && msgs[i].role === 'system') i -= 1;
93
+ const last = msgs[i];
88
94
  if (!last) return 'user';
89
95
  if (last.role === 'assistant') return 'agent';
90
96
  if (Array.isArray(last.content) && last.content.some((b) => b?.type === 'tool_result')) return 'agent';
@@ -190,7 +196,12 @@ export function normalizeCopilotModel(m) {
190
196
  const supports = caps.supports && typeof caps.supports === 'object' ? caps.supports : {};
191
197
  const limits = caps.limits && typeof caps.limits === 'object' ? caps.limits : {};
192
198
  const vendor = typeof m.vendor === 'string' ? m.vendor : '';
193
- const reasoning = supports.reasoning_effort === true || supports.thinking === true || REASONING_ID_RE.test(m.id);
199
+ // Copilot lists a model's reasoning-effort levels (an array); older payloads sent `true`.
200
+ const efforts = Array.isArray(supports.reasoning_effort)
201
+ ? REASONING_EFFORT_LEVELS.filter((e) => supports.reasoning_effort.includes(e))
202
+ : null;
203
+ const reasoning = (efforts !== null && efforts.some((e) => e !== 'none'))
204
+ || supports.reasoning_effort === true || supports.thinking === true || REASONING_ID_RE.test(m.id);
194
205
  return {
195
206
  id: m.id,
196
207
  name: typeof m.name === 'string' && m.name ? m.name : m.id,
@@ -203,6 +214,8 @@ export function normalizeCopilotModel(m) {
203
214
  toolCalls: supports.tool_calls !== false,
204
215
  vision: supports.vision === true,
205
216
  reasoning,
217
+ reasoningEfforts: efforts && efforts.length ? efforts : null,
218
+ endpoints: Array.isArray(m.supported_endpoints) ? m.supported_endpoints.filter((e) => typeof e === 'string') : null,
206
219
  maxPromptTokens: Number(limits.max_prompt_tokens) || null,
207
220
  maxOutputTokens: Number(limits.max_output_tokens) || null,
208
221
  contextWindow: Number(limits.max_context_window_tokens) || null,
@@ -214,19 +227,52 @@ export function isAnthropicVendor(m) {
214
227
  return /anthropic/i.test(m.vendor || '') || /^claude/i.test(m.id || '');
215
228
  }
216
229
 
230
+ /**
231
+ * The wire protocol an imported Copilot model is driven through
232
+ * (2026-09-23-bridge-openai-responses-design.md §11): Anthropic models pass
233
+ * through; an OpenAI model uses the Responses API whenever Copilot serves it
234
+ * there (its reasoning then carries across tool turns); any other model uses
235
+ * it only when Copilot serves it nowhere else; no endpoint list (legacy
236
+ * models) → chat completions.
237
+ */
238
+ export function copilotApiFor(m) {
239
+ if (isAnthropicVendor(m)) return 'anthropic';
240
+ const eps = Array.isArray(m.endpoints) ? m.endpoints : null;
241
+ if (eps && eps.includes('/responses') && (/openai/i.test(m.vendor || '') || !eps.includes('/chat/completions'))) return 'openai-responses';
242
+ return 'openai-chat';
243
+ }
244
+
245
+ /**
246
+ * Worca efforts for an imported Copilot model: every effort (undefined) for an
247
+ * Anthropic model; the Worca efforts among the model's listed levels when
248
+ * Copilot lists them (all four → undefined; none in common → medium, which the
249
+ * bridge maps to the nearest listed level); else every effort for a reasoning
250
+ * model and medium otherwise.
251
+ */
252
+ export function copilotEfforts(m) {
253
+ if (isAnthropicVendor(m)) return undefined;
254
+ if (Array.isArray(m.reasoningEfforts) && m.reasoningEfforts.length) {
255
+ const eff = EFFORTS.filter((e) => m.reasoningEfforts.includes(e));
256
+ if (!eff.length) return ['medium'];
257
+ return eff.length === EFFORTS.length ? undefined : eff;
258
+ }
259
+ return m.reasoning ? undefined : ['medium'];
260
+ }
261
+
217
262
  /** The catalog entry an import creates for a Copilot model (§8.4). */
218
263
  export function catalogEntryForCopilotModel(m) {
219
- const anthropic = isAnthropicVendor(m);
264
+ const api = copilotApiFor(m);
220
265
  const capabilities = {
221
266
  toolCalls: m.toolCalls, vision: m.vision, reasoning: m.reasoning,
222
267
  ...(m.maxPromptTokens ? { maxPromptTokens: m.maxPromptTokens } : {}),
223
268
  ...(m.maxOutputTokens ? { maxOutputTokens: m.maxOutputTokens } : {}),
269
+ ...(api !== 'anthropic' && Array.isArray(m.reasoningEfforts) && m.reasoningEfforts.length ? { reasoningEfforts: m.reasoningEfforts } : {}),
224
270
  };
225
271
  return {
226
272
  id: `copilot-${m.id}`,
227
273
  label: `${m.name} (Copilot)`,
228
- efforts: anthropic || m.reasoning ? undefined : ['medium'],
229
- upstream: { provider: 'copilot', api: anthropic ? 'anthropic' : 'openai-chat', model: m.id, capabilities },
274
+ efforts: copilotEfforts(m),
275
+ upstream: { provider: 'copilot', api, model: m.id, capabilities },
230
276
  cost: { free: true },
231
277
  };
232
278
  }