@aragon-agent/cli 0.6.45 → 0.6.47

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 (71) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +104 -6
  3. package/dist/agent/coalesce.d.ts +4 -0
  4. package/dist/agent/coalesce.js +13 -0
  5. package/dist/agent/controller.d.ts +21 -0
  6. package/dist/agent/controller.js +56 -3
  7. package/dist/agent/invocation-card.d.ts +77 -0
  8. package/dist/agent/invocation-card.js +169 -0
  9. package/dist/agent/prompt-options.d.ts +6 -0
  10. package/dist/agent/queued-messages.d.ts +3 -0
  11. package/dist/agent/queued-messages.js +4 -1
  12. package/dist/agent/reducer.d.ts +25 -0
  13. package/dist/agent/reducer.js +43 -5
  14. package/dist/agent/user-attention.d.ts +17 -0
  15. package/dist/agent/user-attention.js +32 -0
  16. package/dist/cli.js +2 -3
  17. package/dist/commands/builtins.js +12 -0
  18. package/dist/commands/governance-command.js +13 -1
  19. package/dist/commands/index-build.d.ts +8 -0
  20. package/dist/commands/index-build.js +11 -0
  21. package/dist/commands/registry.d.ts +23 -0
  22. package/dist/commands/registry.js +20 -7
  23. package/dist/commands/skills.js +36 -2
  24. package/dist/commands/subagent-command.js +3 -1
  25. package/dist/commands/team-command.d.ts +6 -0
  26. package/dist/commands/team-command.js +40 -3
  27. package/dist/config/load.js +4 -25
  28. package/dist/config/schema.d.ts +4 -4
  29. package/dist/config/schema.js +7 -1
  30. package/dist/proc/limits.d.ts +27 -13
  31. package/dist/proc/limits.js +27 -13
  32. package/dist/proc/progress.d.ts +7 -0
  33. package/dist/proc/progress.js +35 -7
  34. package/dist/proc/prompt.js +7 -4
  35. package/dist/proc/supervisor.js +7 -1
  36. package/dist/proc/types.d.ts +6 -0
  37. package/dist/proc/wait-plan.d.ts +39 -0
  38. package/dist/proc/wait-plan.js +77 -0
  39. package/dist/proc/wait-view.d.ts +65 -0
  40. package/dist/proc/wait-view.js +66 -0
  41. package/dist/proc/wait.d.ts +36 -3
  42. package/dist/proc/wait.js +67 -4
  43. package/dist/session/checkpoint.js +2 -0
  44. package/dist/session/persist.js +29 -2
  45. package/dist/session/validate-session.js +7 -2
  46. package/dist/team/forced-mode.d.ts +19 -0
  47. package/dist/team/forced-mode.js +42 -20
  48. package/dist/tools/job-tools.d.ts +13 -2
  49. package/dist/tools/job-tools.js +156 -33
  50. package/dist/ui/ActivityLine.js +5 -2
  51. package/dist/ui/App.js +59 -6
  52. package/dist/ui/StatusBar.d.ts +1 -0
  53. package/dist/ui/Transcript.js +11 -1
  54. package/dist/ui/entries/InvocationCard.d.ts +20 -0
  55. package/dist/ui/entries/InvocationCard.js +32 -0
  56. package/dist/ui/entries/ToolCard.js +11 -2
  57. package/dist/ui/entries/WaitCard.d.ts +37 -0
  58. package/dist/ui/entries/WaitCard.js +92 -0
  59. package/dist/ui/interaction-copy.d.ts +2 -0
  60. package/dist/ui/interaction-copy.js +3 -0
  61. package/dist/ui/layout/invocation-card.d.ts +41 -0
  62. package/dist/ui/layout/invocation-card.js +91 -0
  63. package/dist/ui/layout/status-layout.d.ts +1 -0
  64. package/dist/ui/layout/status-layout.js +10 -1
  65. package/dist/ui/layout/virtual-window.js +18 -2
  66. package/dist/ui/layout/wait-bar.d.ts +34 -0
  67. package/dist/ui/layout/wait-bar.js +48 -0
  68. package/dist/ui/run-status-row.d.ts +4 -2
  69. package/dist/ui/scrollback/live-preview.js +12 -0
  70. package/dist/ui/transcript-text.js +4 -1
  71. package/package.json +2 -2
@@ -11,6 +11,7 @@
11
11
  * synthesizes a generic error notice (a swallowed throw is never a no-op).
12
12
  */
13
13
  import type { PendingSteering } from './queued-messages.js';
14
+ import type { InvocationCard } from './invocation-card.js';
14
15
  import type { AgentEvent, ModelCost, ToolResult, TokenUsage } from '@aragon-agent/core';
15
16
  import type { AgentMode } from './agent-mode.js';
16
17
  import type { DispatchOutcome, SubagentRun, SubagentSpec, TeamSnapshot } from '../team/types.js';
@@ -18,6 +19,7 @@ import type { FastReview, FastSnapshot } from '../fast/types.js';
18
19
  import type { CompactionMode, CompactionPhase, CompactionRecord, CompactionSnapshot, CompactionUiTrigger, ContextUsageSnapshot } from '../compaction/types.js';
19
20
  import type { AcceptanceSnapshot, TodoItem, TodoSnapshot } from '../todo/types.js';
20
21
  import type { JobKind, ServiceSnapshot, ServiceStatus } from '../proc/types.js';
22
+ import type { WaitView } from '../proc/wait-view.js';
21
23
  import type { RetryPhase } from './retry-view.js';
22
24
  import type { FilePatch } from '../tools/patch.js';
23
25
  import type { TaskSummaryView } from '../fast/task-summary-types.js';
@@ -47,6 +49,7 @@ export type Entry = {
47
49
  id: string;
48
50
  kind: 'user';
49
51
  text: string;
52
+ invocation?: InvocationCard;
50
53
  }
51
54
  /**
52
55
  * A message submitted while a run was in flight (tui-shift-enter-copy-queue
@@ -63,6 +66,7 @@ export type Entry = {
63
66
  kind: 'queued';
64
67
  text: string;
65
68
  queueId?: string;
69
+ invocation?: InvocationCard;
66
70
  } | {
67
71
  id: string;
68
72
  kind: 'assistant';
@@ -137,6 +141,16 @@ export type Entry = {
137
141
  liveSeq?: number;
138
142
  /** Epoch ms of the last output row, for the stall row (§3.3.4). */
139
143
  lastOutputAt?: number;
144
+ /**
145
+ * The live view of a running `job_wait` (extended-job-wait §3.8.1), refreshed
146
+ * at 1 Hz. RUNTIME ONLY, and it lives and dies with `live` (I-9): cleared by
147
+ * `toolExecEnd` AND by every idle path through `releaseLiveTails`, and never
148
+ * persisted. `job_wait` never streams, so `live` is always empty for it - which
149
+ * is exactly why the idle clean-up cannot key off `live` alone.
150
+ */
151
+ wait?: WaitView;
152
+ /** Monotonic counter: the revision term that invalidates the render cache. */
153
+ waitSeq?: number;
140
154
  } | {
141
155
  id: string;
142
156
  kind: 'notice';
@@ -507,6 +521,7 @@ export declare function initialViewState(seed?: ViewStateSeed): ViewState;
507
521
  export type ViewAction = {
508
522
  type: 'submit';
509
523
  text: string;
524
+ invocation?: InvocationCard;
510
525
  }
511
526
  /**
512
527
  * `controller.steer` was accepted; append the persistent queue marker
@@ -517,6 +532,7 @@ export type ViewAction = {
517
532
  type: 'steerQueued';
518
533
  queueId: string;
519
534
  text: string;
535
+ invocation?: InvocationCard;
520
536
  } | {
521
537
  type: 'steeringAccepted';
522
538
  ids: readonly string[];
@@ -575,6 +591,15 @@ export type ViewAction = {
575
591
  toolCallId: string;
576
592
  rows: readonly string[];
577
593
  at: number;
594
+ }
595
+ /**
596
+ * `job_wait`'s 1 Hz view (extended-job-wait). Carries the WHOLE view, so the
597
+ * coalescer keeps only the last per `toolCallId`, like `toolOutputDelta`.
598
+ */
599
+ | {
600
+ type: 'waitUpdate';
601
+ toolCallId: string;
602
+ view: WaitView;
578
603
  } | {
579
604
  type: 'turnEnd';
580
605
  usage: TokenUsage;
@@ -400,12 +400,22 @@ function settleRetryCard(state, entries, phase) {
400
400
  function releaseLiveTails(entries) {
401
401
  let touched = false;
402
402
  const next = entries.map((e) => {
403
- if (e.kind !== 'tool' || !e.live)
403
+ // `!e.live && !e.wait`, NOT `!e.live` (extended-job-wait RV-2): a `job_wait`
404
+ // never streams, so its `live` is always empty and the old guard would skip it,
405
+ // leaving a wait card that says "running" in the transcript forever.
406
+ if (e.kind !== 'tool' || (!e.live && !e.wait))
404
407
  return e;
405
408
  if (e.status !== 'running' && e.status !== 'pending')
406
409
  return e;
407
410
  touched = true;
408
- return { ...e, live: undefined, lastOutputAt: undefined, liveSeq: (e.liveSeq ?? 0) + 1 };
411
+ return {
412
+ ...e,
413
+ live: undefined,
414
+ lastOutputAt: undefined,
415
+ liveSeq: (e.liveSeq ?? 0) + 1,
416
+ wait: undefined,
417
+ waitSeq: (e.waitSeq ?? 0) + 1,
418
+ };
409
419
  });
410
420
  return touched ? next : entries;
411
421
  }
@@ -484,7 +494,10 @@ export function viewReducer(state, action) {
484
494
  switch (action.type) {
485
495
  case 'submit': {
486
496
  const { id, seq } = nextId(state);
487
- const entry = { id, kind: 'user', text: action.text };
497
+ const entry = {
498
+ id, kind: 'user', text: action.text,
499
+ ...(action.invocation ? { invocation: action.invocation } : {}),
500
+ };
488
501
  return {
489
502
  ...state,
490
503
  seq,
@@ -507,7 +520,10 @@ export function viewReducer(state, action) {
507
520
  if (state.seenSteeringIds.has(action.queueId))
508
521
  return state;
509
522
  const { id, seq } = nextId(state);
510
- const entry = { id, kind: 'queued', queueId: action.queueId, text: action.text };
523
+ const entry = {
524
+ id, kind: 'queued', queueId: action.queueId, text: action.text,
525
+ ...(action.invocation ? { invocation: action.invocation } : {}),
526
+ };
511
527
  // NOTHING ELSE RESETS. This is not a new turn -- `turnProduced`,
512
528
  // `errorNoticed` and `aborted` describe the run that is still in
513
529
  // flight, and clearing them here would let the next `runEnd` failure
@@ -515,7 +531,10 @@ export function viewReducer(state, action) {
515
531
  return {
516
532
  ...state, seq, ...appendEntry(state, state.entries, entry),
517
533
  seenSteeringIds: new Set([...state.seenSteeringIds, action.queueId]),
518
- pendingSteering: [...state.pendingSteering, { queueId: action.queueId, text: action.text }],
534
+ pendingSteering: [...state.pendingSteering, {
535
+ queueId: action.queueId, text: action.text,
536
+ ...(action.invocation ? { invocation: action.invocation } : {}),
537
+ }],
519
538
  };
520
539
  }
521
540
  case 'steeringAccepted': {
@@ -660,6 +679,23 @@ export function viewReducer(state, action) {
660
679
  : e),
661
680
  };
662
681
  }
682
+ /**
683
+ * ONLY A RUNNING, MATCHING ENTRY. `begin` travels from the controller and can
684
+ * beat `tool_execution_start` through the agent-event pipe, so a miss is
685
+ * normal and is dropped on purpose: the next 1 Hz beat repairs it (RV-5).
686
+ */
687
+ case 'waitUpdate': {
688
+ const id = findToolEntryId(state, action.toolCallId);
689
+ if (!id)
690
+ return state;
691
+ const target = state.entries.find((e) => e.id === id);
692
+ if (!target || target.kind !== 'tool' || target.status !== 'running')
693
+ return state;
694
+ return {
695
+ ...state,
696
+ entries: mapEntry(state.entries, id, (e) => e.kind === 'tool' ? { ...e, wait: action.view, waitSeq: (e.waitSeq ?? 0) + 1 } : e),
697
+ };
698
+ }
663
699
  case 'toolExecEnd': {
664
700
  const id = findToolEntryId(state, action.toolCallId);
665
701
  if (!id)
@@ -680,6 +716,8 @@ export function viewReducer(state, action) {
680
716
  // `bashBadge`'s last-line parse (`ToolPreview.tsx:35-54`).
681
717
  live: undefined,
682
718
  lastOutputAt: undefined,
719
+ wait: undefined,
720
+ waitSeq: undefined,
683
721
  }
684
722
  : e),
685
723
  };
@@ -0,0 +1,17 @@
1
+ /**
2
+ * "The user just spoke" as a subscribable edge (extended-job-wait §3.4).
3
+ *
4
+ * ASCII ONLY: `src/agent/**` is inside the glyph scanner's scope.
5
+ *
6
+ * EDGE-TRIGGERED ON PURPOSE: there is no "is anything pending" probe. A wait that
7
+ * could be woken by pre-existing state would return at once whenever that state
8
+ * went stale, and a model that gets an instant return from every `job_wait` burns
9
+ * tokens in a hot loop. Only a `notify()` that happens after `subscribe()` counts.
10
+ */
11
+ export interface UserAttention {
12
+ /** Announce one new user input to every current subscriber. */
13
+ notify(): void;
14
+ /** Register a listener; returns its unsubscribe. */
15
+ subscribe(listener: () => void): () => void;
16
+ }
17
+ export declare function createUserAttention(): UserAttention;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * "The user just spoke" as a subscribable edge (extended-job-wait §3.4).
3
+ *
4
+ * ASCII ONLY: `src/agent/**` is inside the glyph scanner's scope.
5
+ *
6
+ * EDGE-TRIGGERED ON PURPOSE: there is no "is anything pending" probe. A wait that
7
+ * could be woken by pre-existing state would return at once whenever that state
8
+ * went stale, and a model that gets an instant return from every `job_wait` burns
9
+ * tokens in a hot loop. Only a `notify()` that happens after `subscribe()` counts.
10
+ */
11
+ export function createUserAttention() {
12
+ const listeners = new Set();
13
+ return {
14
+ notify() {
15
+ // Snapshot first: a listener may unsubscribe itself while we iterate.
16
+ for (const listener of [...listeners]) {
17
+ try {
18
+ listener();
19
+ }
20
+ catch {
21
+ // One broken listener must not deprive the others of the wake-up.
22
+ }
23
+ }
24
+ },
25
+ subscribe(listener) {
26
+ listeners.add(listener);
27
+ return () => {
28
+ listeners.delete(listener);
29
+ };
30
+ },
31
+ };
32
+ }
package/dist/cli.js CHANGED
@@ -1765,9 +1765,8 @@ function runConfigSetValue(key, value) {
1765
1765
  const jobsPatch = applyJobsConfigSet(key, value);
1766
1766
  if (jobsPatch) {
1767
1767
  // Echoed from the STORED value, so `config set jobs.waitSeconds 1` prints 5
1768
- // and `jobs.waitSeconds 9999` prints 60 — the rule every section above
1769
- // states. It matters here too: `waitSeconds` is ALSO converged against the
1770
- // tool ceiling by I-4, so the stored value is the only honest answer.
1768
+ // and `jobs.waitSeconds 9999` prints 1200 — the rule every section above
1769
+ // states.
1771
1770
  const merged = updatePersistedConfig(jobsPatch);
1772
1771
  const field = key.slice('jobs.'.length);
1773
1772
  process.stdout.write(`Set ${key} = ${String(merged.jobs[field])}\n`);
@@ -621,6 +621,8 @@ const COMMANDS = [
621
621
  },
622
622
  {
623
623
  name: 'todo',
624
+ // Same verb parse as `run` below, so the echo is suppressed exactly when a card can appear (I-8).
625
+ rendersInvocationCard: (args) => args.trim().toLowerCase().split(/\s+/)[0] === 'continue',
624
626
  description: 'Todo planning: status | on | off | panel on|off | follow <mode> | ' +
625
627
  'acceptance [on|off|reset] | clear | continue',
626
628
  /**
@@ -782,6 +784,16 @@ const COMMANDS = [
782
784
  // the more verbose of the two forms (AC-17).
783
785
  ctx.submit(buildContinuationMessage(snapshot), {
784
786
  userInitiated: false, todoPolicy: 'continue',
787
+ // The transcript shows a card instead of the canned paragraph; the payload above
788
+ // is unchanged (tui-invocation-cards I-1).
789
+ ...(ctx.invocationCards === false ? {} : {
790
+ invocation: {
791
+ variant: 'todo',
792
+ name: 'continue',
793
+ commandLine: '/todo continue',
794
+ facts: [`${Math.max(0, snapshot.total - snapshot.doneCount)} of ${snapshot.total} steps left`],
795
+ },
796
+ }),
785
797
  });
786
798
  return;
787
799
  }
@@ -198,7 +198,17 @@ async function resume(ctx) {
198
198
  return;
199
199
  // `userInitiated: false` keeps the canned turn out of the prompt history, exactly like
200
200
  // `/todo continue` (todo-plan-followthrough C-12).
201
- ctx.submit(resumeTurnText(runId), { userInitiated: false });
201
+ ctx.submit(resumeTurnText(runId), {
202
+ userInitiated: false,
203
+ ...(ctx.invocationCards === false ? {} : {
204
+ invocation: {
205
+ variant: 'govern',
206
+ name: 'resume',
207
+ commandLine: requested === '' ? '/gov resume' : `/gov resume ${requested}`,
208
+ facts: [`run ${runId.slice(0, 8)}`],
209
+ },
210
+ }),
211
+ });
202
212
  }
203
213
  function abort(ctx) {
204
214
  if (!ctx.controller.isGovernanceStageRunning()) {
@@ -286,6 +296,8 @@ export async function runGovernanceCommand(ctx) {
286
296
  export function registerGovernanceCommand(registry) {
287
297
  registry.register({
288
298
  name: 'gov',
299
+ // Same verb parse as `runGovernanceCommand`, so the echo is suppressed exactly when a card can appear (I-8).
300
+ rendersInvocationCard: (args) => (args.trim().split(/\s+/)[0] ?? '').toLowerCase() === 'resume',
289
301
  description: GOV_COMMAND_SUMMARY,
290
302
  run: (ctx) => runGovernanceCommand(ctx),
291
303
  });
@@ -26,6 +26,14 @@ export declare function hasProjectIndex(cwd: string): boolean;
26
26
  * second time (the skill's own edge-case list says exactly that).
27
27
  */
28
28
  export declare function indexBuildInvocationArgs(hasExistingIndex: boolean): string;
29
+ /**
30
+ * True when `args` is one of the two machine-written sentences above. The invocation
31
+ * card uses this to NOT present them as something the user said (tui-invocation-cards
32
+ * RV-3): it shows a `ctrl+i` fact instead. Same file as the generator so the two cannot
33
+ * drift - a reworded sentence that stops matching would silently put machine words
34
+ * back on the card.
35
+ */
36
+ export declare function isIndexBuildArgs(args: string): boolean;
29
37
  export interface IndexConfirmInput {
30
38
  cwd: string;
31
39
  /** True when `.claude-index/index.md` already exists (rebuild, not first build). */
@@ -40,6 +40,17 @@ export function indexBuildInvocationArgs(hasExistingIndex) {
40
40
  ? 'The user pressed Ctrl+I and confirmed. Rebuild the project index for this workspace now, reusing the existing .claude-index/config.md preferences.'
41
41
  : 'The user pressed Ctrl+I and confirmed. Build the project index for this workspace now, asking the configuration questions first.';
42
42
  }
43
+ /**
44
+ * True when `args` is one of the two machine-written sentences above. The invocation
45
+ * card uses this to NOT present them as something the user said (tui-invocation-cards
46
+ * RV-3): it shows a `ctrl+i` fact instead. Same file as the generator so the two cannot
47
+ * drift - a reworded sentence that stops matching would silently put machine words
48
+ * back on the card.
49
+ */
50
+ export function isIndexBuildArgs(args) {
51
+ const text = args.trim();
52
+ return text === indexBuildInvocationArgs(true) || text === indexBuildInvocationArgs(false);
53
+ }
43
54
  /**
44
55
  * The Yes/No dialog body. It names WHAT WILL BE WRITTEN - an index run is a
45
56
  * batch of file writes the user never typed, and the one question a confirm
@@ -201,6 +201,13 @@ export interface CommandContext {
201
201
  * TYPE-ONLY import, for the reason the `branch` note above records.
202
202
  */
203
203
  taskSummary?: TaskSummaryCommandPort;
204
+ /**
205
+ * Whether transcript invocation cards are on (tui-invocation-cards section 3.6).
206
+ * `false` only under `ARAGON_NO_INVOCATION_CARDS=1`; commands then fall back to the
207
+ * pre-card notices and submit without `invocation`. Absent (every fixture built by
208
+ * literal) means ON.
209
+ */
210
+ invocationCards?: boolean;
204
211
  }
205
212
  /**
206
213
  * What `/keys` and `/task` may do.
@@ -240,6 +247,12 @@ export interface SlashCommand {
240
247
  aliases?: string[];
241
248
  description: string;
242
249
  run: (ctx: CommandContext) => void | Promise<void>;
250
+ /**
251
+ * True when running with these args will submit a message that renders as an invocation
252
+ * card, so the `Command:` echo would only repeat it. MUST be the same predicate the
253
+ * command itself branches on (I-8); absent means false.
254
+ */
255
+ rendersInvocationCard?: (args: string) => boolean;
243
256
  }
244
257
  export declare class CommandRegistry {
245
258
  private readonly byName;
@@ -247,6 +260,16 @@ export declare class CommandRegistry {
247
260
  get(name: string): SlashCommand | undefined;
248
261
  all(): SlashCommand[];
249
262
  }
263
+ /**
264
+ * Split raw composer input into a command word and its trimmed args; `null` when the
265
+ * input is not a slash command (no leading `/`, or the `//` literal-slash escape).
266
+ * `runSlashInput` and the App's echo suppression both go through here so they cannot
267
+ * disagree about what counts as a command.
268
+ */
269
+ export declare function parseSlashInput(input: string): {
270
+ name: string;
271
+ args: string;
272
+ } | null;
250
273
  /**
251
274
  * Try to run a slash command from raw input. Returns `true` if the input was a
252
275
  * (known or unknown) slash command and was consumed; `false` if it is a normal
@@ -19,21 +19,34 @@ export class CommandRegistry {
19
19
  }
20
20
  }
21
21
  /**
22
- * Try to run a slash command from raw input. Returns `true` if the input was a
23
- * (known or unknown) slash command and was consumed; `false` if it is a normal
24
- * message that should go to the agent.
22
+ * Split raw composer input into a command word and its trimmed args; `null` when the
23
+ * input is not a slash command (no leading `/`, or the `//` literal-slash escape).
24
+ * `runSlashInput` and the App's echo suppression both go through here so they cannot
25
+ * disagree about what counts as a command.
25
26
  */
26
- export async function runSlashInput(registry, input, makeCtx) {
27
+ export function parseSlashInput(input) {
27
28
  const trimmed = input.trimStart();
28
29
  if (!trimmed.startsWith('/'))
29
- return false;
30
- // `//...` escapes a literal leading slash — not a command.
30
+ return null;
31
+ // `//...` escapes a literal leading slash - not a command.
31
32
  if (trimmed.startsWith('//'))
32
- return false;
33
+ return null;
33
34
  const withoutSlash = trimmed.slice(1);
34
35
  const spaceIdx = withoutSlash.search(/\s/);
35
36
  const name = spaceIdx === -1 ? withoutSlash : withoutSlash.slice(0, spaceIdx);
36
37
  const args = spaceIdx === -1 ? '' : withoutSlash.slice(spaceIdx + 1).trim();
38
+ return { name, args };
39
+ }
40
+ /**
41
+ * Try to run a slash command from raw input. Returns `true` if the input was a
42
+ * (known or unknown) slash command and was consumed; `false` if it is a normal
43
+ * message that should go to the agent.
44
+ */
45
+ export async function runSlashInput(registry, input, makeCtx) {
46
+ const parsed = parseSlashInput(input);
47
+ if (parsed === null)
48
+ return false;
49
+ const { name, args } = parsed;
37
50
  const command = registry.get(name);
38
51
  const ctx = makeCtx(args);
39
52
  if (!command) {
@@ -16,6 +16,7 @@ import { SKILL_TRIAGE_LIMITS } from '../skills/triage.js';
16
16
  import { HOST_TOOL_NAMES, SKILL_TOOL_FLOOR } from '../tools/index.js';
17
17
  import { SKILLS_TOOL_POLICY_MODES, isSkillsToolPolicyMode } from '../config/schema.js';
18
18
  import { pickGlyphs } from '../ui/glyphs.js';
19
+ import { INDEX_SKILL_NAME, isIndexBuildArgs } from './index-build.js';
19
20
  function marksFor(ctx) {
20
21
  const cfg = ctx.controller.getConfig();
21
22
  const glyphs = pickGlyphs({ colorLevel: cfg.colorLevel ?? 0, unicode: cfg.unicode ?? false });
@@ -673,10 +674,37 @@ export function registerSkillCommands(registry, service) {
673
674
  }
674
675
  }
675
676
  }
677
+ /**
678
+ * The card's raw material for one skill run (tui-invocation-cards section 3.3). The
679
+ * command line always uses the namespaced `/skill:<name>` form - the stable handle -
680
+ * even when the user typed the bare name. Ctrl+I's machine-written sentence is NOT the
681
+ * user's input (RV-3): the card says `ctrl+i` and shows no input row.
682
+ */
683
+ function skillCardInput(record, args, sent) {
684
+ const machine = record.name === INDEX_SKILL_NAME && isIndexBuildArgs(args);
685
+ const version = record.frontmatter.version;
686
+ const facts = [
687
+ ...(machine ? ['ctrl+i'] : []),
688
+ ...(version ? [`v${version}`] : []),
689
+ record.scope,
690
+ `${sent.kb} KB`,
691
+ ...(sent.files > 0 ? [`${sent.files} files`] : []),
692
+ ...(sent.policy ? ['tool ceiling'] : []),
693
+ ];
694
+ const typed = machine ? '' : args;
695
+ return {
696
+ variant: 'skill',
697
+ name: record.name,
698
+ commandLine: typed === '' ? `/skill:${record.name}` : `/skill:${record.name} ${typed}`,
699
+ input: typed,
700
+ facts,
701
+ };
702
+ }
676
703
  function makeSkillCommand(record, service) {
677
704
  return {
678
705
  name: record.name,
679
706
  description: shortDescription(record),
707
+ rendersInvocationCard: () => true,
680
708
  run: (ctx) => {
681
709
  let loaded;
682
710
  try {
@@ -703,8 +731,14 @@ function makeSkillCommand(record, service) {
703
731
  // The SUBMITTED size, not the file's. Reporting 480 KB while sending 30 KB
704
732
  // is how a user concludes the truncation marker is a rendering glitch.
705
733
  const kb = Math.max(1, Math.round(Buffer.byteLength(message, 'utf-8') / 1024));
706
- ctx.notify('info', `Skill "${record.name}" loaded (${kb} KB submitted, ${loaded.files.length} bundled files).`);
707
- ctx.submit(message);
734
+ if (ctx.invocationCards === false) {
735
+ ctx.notify('info', `Skill "${record.name}" loaded (${kb} KB submitted, ${loaded.files.length} bundled files).`);
736
+ ctx.submit(message);
737
+ return;
738
+ }
739
+ ctx.submit(message, {
740
+ invocation: skillCardInput(record, ctx.args, { kb, files: loaded.files.length, policy: !!policy }),
741
+ });
708
742
  },
709
743
  };
710
744
  }
@@ -17,7 +17,7 @@
17
17
  * file therefore holds ONE delegation and one line of deprecation text, and
18
18
  * `subagent-command.test.ts` asserts both (I-8).
19
19
  */
20
- import { runTeamCommand } from './team-command.js';
20
+ import { runTeamCommand, teamRendersInvocationCard } from './team-command.js';
21
21
  /**
22
22
  * Run `/team`, having first said out loud that this is the same thing.
23
23
  *
@@ -33,6 +33,8 @@ export function runSubagentCommand(ctx) {
33
33
  export function registerSubagentCommand(registry) {
34
34
  registry.register({
35
35
  name: 'subagent',
36
+ // A fresh object inherits nothing from `/team`: reference the SAME predicate (RV-5).
37
+ rendersInvocationCard: teamRendersInvocationCard,
36
38
  description: 'Deprecated alias of /team - same arguments',
37
39
  run: (ctx) => { runSubagentCommand(ctx); },
38
40
  });
@@ -43,6 +43,12 @@
43
43
  * `Team 4` chip would wait for an unrelated state change before appearing.
44
44
  */
45
45
  import type { CommandContext, CommandRegistry } from './registry.js';
46
+ /**
47
+ * Whether `/team <args>` ends in a card. The SAME function the command branches on
48
+ * (`readTeamIntent`), and shared by `/team` and the `/subagent` alias by reference: the
49
+ * alias builds a fresh object and inherits nothing (RV-5, I-8).
50
+ */
51
+ export declare function teamRendersInvocationCard(args: string): boolean;
46
52
  /** The command body; `registerTeamCommand` is the only registration site. */
47
53
  export declare function runTeamCommand(ctx: CommandContext): void;
48
54
  /** Registered from `builtins.ts` with one line, next to every other subsystem. */
@@ -43,7 +43,7 @@
43
43
  * `Team 4` chip would wait for an unrelated state change before appearing.
44
44
  */
45
45
  import { buildForcedDirective } from '../team/forced-dispatch.js';
46
- import { DEFAULT_FORCED_COUNT, planForced, planNotice, readTeamIntent, } from '../team/forced-mode.js';
46
+ import { DEFAULT_FORCED_COUNT, forcedCardNote, planForced, planNotice, readTeamIntent, } from '../team/forced-mode.js';
47
47
  import { subagentModelSummary } from '../ui/overlays/model-profile-state.js';
48
48
  /**
49
49
  * The subagent model line, phrased by the same authority the settings root row
@@ -199,6 +199,34 @@ function enterForcedMode(ctx) {
199
199
  ctx.notify('info', `Forced team mode on: ${adopted} subagents per turn. `
200
200
  + 'Your next message dispatches.');
201
201
  }
202
+ /**
203
+ * The card's raw material for `/team <brief>` (tui-invocation-cards section 3.3). The
204
+ * command line is always spelled `/team`, even when the user came in through the
205
+ * deprecated `/subagent` alias: the card names the canonical command.
206
+ */
207
+ function forcedCardInput(args, planned, note, ctx) {
208
+ const noun = planned.count === 1 ? 'subagent' : 'subagents';
209
+ const facts = [
210
+ planned.parts.length > 0 ? `parts ${planned.parts.join(', ')}` : `${planned.count} ${noun}`,
211
+ ...(planned.readOnly ? ['read-only'] : []),
212
+ ...(ctx.controller.getAgentMode() === 'plan' ? ['plan mode'] : []),
213
+ ];
214
+ return {
215
+ variant: 'team',
216
+ commandLine: args === '' ? '/team' : `/team ${args}`,
217
+ input: planned.brief,
218
+ facts,
219
+ ...(note !== undefined ? { note } : {}),
220
+ };
221
+ }
222
+ /**
223
+ * Whether `/team <args>` ends in a card. The SAME function the command branches on
224
+ * (`readTeamIntent`), and shared by `/team` and the `/subagent` alias by reference: the
225
+ * alias builds a fresh object and inherits nothing (RV-5, I-8).
226
+ */
227
+ export function teamRendersInvocationCard(args) {
228
+ return readTeamIntent(args).kind === 'brief';
229
+ }
202
230
  /** `/team [-n n|--parts a,b] [--read-only] <brief>`: arm and submit in one act. */
203
231
  function armAndSubmit(ctx, intent) {
204
232
  const team = ctx.controller.getTeamConfig();
@@ -209,7 +237,11 @@ function armAndSubmit(ctx, intent) {
209
237
  // directive names: the later turns reuse this count, so the first turn and the
210
238
  // rest of the session cannot disagree about how wide the fan-out is (D-7).
211
239
  ctx.controller.armForcedTeam(planned.count);
212
- ctx.notify('info', planNotice(planned, team));
240
+ // With cards on, the disclosure rides on the card (`forcedCardNote`, same source as
241
+ // `planNotice`); with them off this is the pre-card notice, byte for byte.
242
+ const cards = ctx.invocationCards !== false;
243
+ if (!cards)
244
+ ctx.notify('info', planNotice(planned, team));
213
245
  const directive = buildForcedDirective({
214
246
  brief: planned.brief,
215
247
  count: planned.count,
@@ -222,7 +254,11 @@ function armAndSubmit(ctx, intent) {
222
254
  });
223
255
  // `userInitiated: false`: a directive the user did not type belongs in neither
224
256
  // the prompt history nor the hint-fade counter (todo-plan-followthrough C-12).
225
- ctx.submit(directive, { userInitiated: false });
257
+ const note = forcedCardNote(planned, team);
258
+ ctx.submit(directive, {
259
+ userInitiated: false,
260
+ ...(cards ? { invocation: forcedCardInput(ctx.args, planned, note, ctx) } : {}),
261
+ });
226
262
  }
227
263
  /** The command body; `registerTeamCommand` is the only registration site. */
228
264
  export function runTeamCommand(ctx) {
@@ -253,6 +289,7 @@ export function runTeamCommand(ctx) {
253
289
  export function registerTeamCommand(registry) {
254
290
  registry.register({
255
291
  name: 'team',
292
+ rendersInvocationCard: teamRendersInvocationCard,
256
293
  description: 'Force team mode and start now: /team <brief>; /team [on|off|status]',
257
294
  run: (ctx) => { runTeamCommand(ctx); },
258
295
  });
@@ -6,7 +6,6 @@
6
6
  * legitimately long single tool run can never be watchdog-killed.
7
7
  */
8
8
  import process from 'node:process';
9
- import { JOB_WAIT_MARGIN_MS } from '../proc/limits.js';
10
9
  import { DEFAULT_CONFIG, DEFAULT_SCROLL_MODE, IDLE_TIMEOUT_MARGIN_MS, clampDensity, clampHumanTimeout, clampCompactionConfig, clampLogConfig, clampFastConfig, clampRetryConfig, clampSkillsConfig, clampTeamConfig, clampTodoConfig, clampBashConfig, clampJobsConfig, clampPromptsConfig, clampTaskSummaryConfig, clampUpdateConfig, clampTheme, clampThinkingLevel, clampContextWindow, clampMaxTokens, clampMaxRenderInterval, clampScrollMode, clampScrollResumeMs, clampTranscriptRetain, clampTranscriptWindow, coercePositiveInt, isAutoToken, isSkillsToolPolicyMode, legacyTeamCeilingPatch, legacyTeamFanoutPatch, LEGACY_TEAM_FANOUT_DEFAULTS, parseThresholdInput, } from './schema.js';
11
10
  import { loadDotenv, readEnvConfig, readEnvDisabledSkills } from './env.js';
12
11
  import { readConfigFile, updatePersistedConfig } from './store.js';
@@ -622,32 +621,14 @@ export function loadConfig(flags = {}, options = {}) {
622
621
  DEFAULT_CONFIG.planModeDefault;
623
622
  const planModeHumanTimeoutMs = clampHumanTimeout(file.planModeHumanTimeoutMs, DEFAULT_CONFIG.planModeHumanTimeoutMs);
624
623
  const toolTimeoutMs = coercePositiveInt(pick(flags.toolTimeout, file.toolTimeoutMs), DEFAULT_CONFIG.toolTimeoutMs);
625
- // THE `jobs` SECTION IS RESOLVED HERE, ABOVE THE TIMEOUT BLOCK, and the
626
- // position is the whole of RV-P1-2. The design's first draft put the I-4 clamp
627
- // next to the `idleTimeoutMs` invariant below, but `jobs` is parsed inside the
628
- // big config literal a hundred lines further down — a reference to it here would
629
- // be a use-before-declaration, and "resolve it later and patch the object" is
630
- // how the invariant gets silently skipped. Resolving it first makes the clamp
631
- // impossible to omit.
624
+ // `jobs.waitSeconds` is NOT converged against `toolTimeoutMs` (extended-job-wait
625
+ // I-4'): `job_wait` has its own executor ceiling and pauses the idle watchdog,
626
+ // so the section is resolved once, as-is.
632
627
  const jobs = resolveJobsConfig(env.partial, file);
633
628
  let idleTimeoutMs = coercePositiveInt(pick(flags.idleTimeout, file.idleTimeoutMs), DEFAULT_CONFIG.idleTimeoutMs);
634
629
  // Timeout invariant (R1): idle must be strictly greater than the tool ceiling
635
630
  // so a long single tool run is never aborted by the idle watchdog.
636
631
  idleTimeoutMs = Math.max(idleTimeoutMs, toolTimeoutMs + IDLE_TIMEOUT_MARGIN_MS);
637
- // INVARIANT I-4 (long-task-jobs): a wait must fit inside the tool call that
638
- // hosts it, `jobs.waitSeconds * 1000 + JOB_WAIT_MARGIN_MS <= toolTimeoutMs`.
639
- //
640
- // `Math.min`, AND THE DIRECTION IS THE POINT. The invariant above RAISES the
641
- // idle window to accommodate the tool; this one LOWERS the wait to accommodate
642
- // the tool. Copying `Math.max` here would produce a wait that outlives its own
643
- // tool call — which is exactly the `Command timed out` failure the feature
644
- // exists to remove, re-created by its own config check.
645
- //
646
- // With an operator-set ceiling below the margin the floor of 1s applies rather
647
- // than a negative wait: the invariant wins over the minimum, because a wait
648
- // longer than its host is worse than a short wait.
649
- const waitCeilingSeconds = Math.floor((toolTimeoutMs - JOB_WAIT_MARGIN_MS) / 1000);
650
- jobs.waitSeconds = Math.max(1, Math.min(jobs.waitSeconds, waitCeilingSeconds));
651
632
  // Resolve API keys: config-file key wins over the provider env var.
652
633
  const fileKeys = file.apiKeys ?? {};
653
634
  const apiKeys = {};
@@ -798,9 +779,7 @@ export function loadConfig(flags = {}, options = {}) {
798
779
  update: resolveUpdateConfig(flags, env.partial, file),
799
780
  compaction: resolveCompactionConfig(flags, env.partial, file),
800
781
  bash: resolveBashConfig(env.partial, file),
801
- // The already-resolved-and-converged section from the timeout block above.
802
- // NOT `resolveJobsConfig(...)` a second time: that would re-apply the
803
- // un-converged `waitSeconds` and silently undo I-4.
782
+ // The already-resolved section from the top of this function.
804
783
  jobs,
805
784
  // The quick-prompt library's three policy scalars (quick-prompt-library D-19).
806
785
  // Resolved through the SAME clamp the read and write paths use, so an env-only