@robota-sdk/agent-command 3.0.0-beta.79 → 3.0.0-beta.81

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 (200) hide show
  1. package/README.md +11 -2
  2. package/dist/node/index.cjs +76 -38
  3. package/dist/node/index.d.cts +946 -0
  4. package/dist/node/index.d.cts.map +1 -0
  5. package/dist/node/index.d.ts +564 -51
  6. package/dist/node/index.d.ts.map +1 -1
  7. package/dist/node/index.js +76 -38
  8. package/dist/node/index.js.map +1 -1
  9. package/package.json +34 -16
  10. package/src/advisor/advisor-command-module.ts +47 -0
  11. package/src/advisor/advisor-command.test.ts +102 -0
  12. package/src/advisor/advisor-command.ts +55 -0
  13. package/src/advisor/advisor-cost-functional.test.ts +92 -0
  14. package/src/advisor/index.ts +6 -0
  15. package/src/agent/__tests__/agent-command.test.ts +39 -8
  16. package/src/agent/agent-command-module.ts +14 -7
  17. package/src/agent/agent-command-parser.ts +1 -1
  18. package/src/agent/agent-command.ts +3 -3
  19. package/src/background/__tests__/background-command-module.test.ts +59 -52
  20. package/src/background/background-command-module.ts +2 -1
  21. package/src/background/background-command.ts +3 -3
  22. package/src/command-module-utils.ts +33 -0
  23. package/src/compact/__tests__/compact-command-module.test.ts +31 -23
  24. package/src/compact/compact-command-module.ts +10 -1
  25. package/src/compact/compact-command.ts +6 -3
  26. package/src/context/__tests__/context-command-module.test.ts +214 -63
  27. package/src/context/auto-compact-format.ts +25 -0
  28. package/src/context/context-breakdown.ts +295 -0
  29. package/src/context/context-command-module.ts +45 -14
  30. package/src/context/context-command.ts +34 -257
  31. package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +110 -0
  32. package/src/default/__tests__/default-command-modules.test.ts +59 -7
  33. package/src/default/__tests__/model-exposure.test.ts +241 -0
  34. package/src/default/__tests__/org-policy-forwarding.test.ts +98 -0
  35. package/src/default/default-command-modules.ts +130 -18
  36. package/src/default/index.ts +4 -1
  37. package/src/devices/__tests__/devices-command-module.test.ts +212 -0
  38. package/src/devices/devices-command-module.ts +224 -0
  39. package/src/devices/devices-command-port.ts +82 -0
  40. package/src/devices/index.ts +11 -0
  41. package/src/doctor/__tests__/doctor-command-module.test.ts +145 -0
  42. package/src/doctor/__tests__/doctor-display-vocabulary.test.ts +38 -0
  43. package/src/doctor/__tests__/doctor-fixture.ts +145 -0
  44. package/src/doctor/__tests__/doctor-redaction.test.ts +29 -0
  45. package/src/doctor/__tests__/doctor-runner.test.ts +464 -0
  46. package/src/doctor/doctor-command-module.ts +132 -0
  47. package/src/doctor/doctor-extensions-probe.ts +299 -0
  48. package/src/doctor/doctor-node-deps.ts +89 -0
  49. package/src/doctor/doctor-redaction.ts +99 -0
  50. package/src/doctor/doctor-render.ts +70 -0
  51. package/src/doctor/doctor-repair.ts +136 -0
  52. package/src/doctor/doctor-runner.ts +122 -0
  53. package/src/doctor/doctor-settings-probe.ts +239 -0
  54. package/src/doctor/doctor-storage-probe.ts +187 -0
  55. package/src/doctor/doctor-types.ts +109 -0
  56. package/src/doctor/index.ts +32 -0
  57. package/src/editor/__tests__/editor-command-functional.test.ts +49 -16
  58. package/src/editor/editor-command-module.ts +6 -5
  59. package/src/editor/editor-command.ts +10 -5
  60. package/src/effort/effort-command-module.ts +51 -0
  61. package/src/effort/effort-command.test.ts +140 -0
  62. package/src/effort/effort-command.ts +122 -0
  63. package/src/effort/index.ts +6 -0
  64. package/src/exit/__tests__/exit-command-module.test.ts +16 -9
  65. package/src/exit/exit-command-module.ts +2 -1
  66. package/src/exit/exit-command.ts +6 -6
  67. package/src/fork/__tests__/fork-command.test.ts +184 -0
  68. package/src/fork/fork-command-module.ts +61 -0
  69. package/src/fork/fork-command.ts +162 -0
  70. package/src/fork/index.ts +8 -0
  71. package/src/git/__tests__/fake-git-port.ts +28 -0
  72. package/src/git/__tests__/git-command-module.test.ts +103 -0
  73. package/src/git/__tests__/git-commit.test.ts +185 -0
  74. package/src/git/__tests__/git-diff.test.ts +125 -0
  75. package/src/git/__tests__/git-process.test.ts +221 -0
  76. package/src/git/__tests__/git-status.test.ts +117 -0
  77. package/src/git/git-command-module.ts +139 -0
  78. package/src/git/git-commit.ts +251 -0
  79. package/src/git/git-diff.ts +129 -0
  80. package/src/git/git-process.ts +169 -0
  81. package/src/git/git-status.ts +134 -0
  82. package/src/git/index.ts +17 -0
  83. package/src/goal/__tests__/goal-command.test.ts +9 -4
  84. package/src/goal/goal-command-module.ts +2 -1
  85. package/src/goal/goal-command.ts +6 -9
  86. package/src/handoff/__tests__/handoff-command.test.ts +218 -0
  87. package/src/handoff/handoff-command-module.ts +48 -0
  88. package/src/handoff/handoff-command.ts +187 -0
  89. package/src/handoff/index.ts +6 -0
  90. package/src/help/__tests__/help-command-module.test.ts +63 -40
  91. package/src/help/__tests__/help-command.test.ts +46 -37
  92. package/src/help/help-command-module.ts +2 -1
  93. package/src/help/help-command.ts +3 -3
  94. package/src/index.ts +124 -2
  95. package/src/keybindings/__tests__/keybindings-command-module.test.ts +57 -0
  96. package/src/keybindings/index.ts +6 -0
  97. package/src/keybindings/keybindings-command-module.ts +74 -0
  98. package/src/language/__tests__/language-command-module.test.ts +33 -28
  99. package/src/language/language-command-module.ts +2 -1
  100. package/src/language/language-command.ts +8 -6
  101. package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +630 -0
  102. package/src/mcp-activation/__tests__/mcp-model-view.test.ts +211 -0
  103. package/src/mcp-activation/index.ts +12 -0
  104. package/src/mcp-activation/mcp-activation-command-module.ts +92 -0
  105. package/src/mcp-activation/mcp-activation-command.ts +448 -0
  106. package/src/mcp-activation/mcp-model-notice.ts +85 -0
  107. package/src/mcp-activation/mcp-model-status.ts +99 -0
  108. package/src/memory/__tests__/memory-command-module.test.ts +167 -74
  109. package/src/memory/memory-command-module.ts +18 -1
  110. package/src/memory/memory-command.ts +38 -38
  111. package/src/mode/__tests__/mode-command-module.test.ts +51 -38
  112. package/src/mode/mode-command-module.ts +2 -1
  113. package/src/mode/mode-command.ts +15 -7
  114. package/src/output-style/__tests__/output-style-command.test.ts +96 -0
  115. package/src/output-style/index.ts +6 -0
  116. package/src/output-style/output-style-command-module.ts +143 -0
  117. package/src/peers/__tests__/peers-command.test.ts +241 -0
  118. package/src/peers/index.ts +9 -0
  119. package/src/peers/peers-command-module.ts +47 -0
  120. package/src/peers/peers-command.ts +142 -0
  121. package/src/permissions/__tests__/permissions-command-module.test.ts +149 -37
  122. package/src/permissions/permissions-command-module.ts +2 -1
  123. package/src/permissions/permissions-command.ts +46 -4
  124. package/src/plan/__tests__/plan-command.test.ts +129 -0
  125. package/src/plan/index.ts +9 -0
  126. package/src/plan/plan-command-module.ts +49 -0
  127. package/src/plan/plan-command.ts +92 -0
  128. package/src/plugin/__tests__/plugin-command-module.test.ts +46 -40
  129. package/src/plugin/__tests__/plugin-host-boundary.test.ts +11 -0
  130. package/src/plugin/plugin-command-module.ts +3 -1
  131. package/src/plugin/plugin-command.ts +17 -16
  132. package/src/preset/__tests__/host-supplied-preset-registry.test.ts +98 -0
  133. package/src/preset/__tests__/preset-command-module.test.ts +102 -56
  134. package/src/preset/preset-command-module.ts +26 -8
  135. package/src/preset/preset-command.ts +85 -27
  136. package/src/provider/__tests__/org-policy.test.ts +4 -5
  137. package/src/provider/__tests__/provider-command-module.test.ts +11 -14
  138. package/src/provider/__tests__/provider-startup-host-settings.test.ts +16 -0
  139. package/src/provider/__tests__/scripted-interaction.ts +7 -5
  140. package/src/provider/provider-command-execution.ts +4 -4
  141. package/src/provider/provider-command-module.ts +2 -1
  142. package/src/provider/provider-command-profile-lifecycle.ts +4 -4
  143. package/src/provider/provider-command-profile-operations.ts +3 -3
  144. package/src/provider/provider-command-profile.ts +1 -1
  145. package/src/provider/provider-command-setup.ts +4 -4
  146. package/src/provider/provider-startup.ts +62 -35
  147. package/src/remote-control/__tests__/remote-control-command-module.test.ts +36 -0
  148. package/src/remote-control/__tests__/remote-control-command.test.ts +216 -0
  149. package/src/remote-control/index.ts +8 -0
  150. package/src/remote-control/remote-control-command-module.ts +61 -0
  151. package/src/remote-control/remote-control-command.ts +138 -0
  152. package/src/reset/__tests__/reset-command-module.test.ts +5 -5
  153. package/src/reset/reset-command-module.ts +2 -1
  154. package/src/reset/reset-command.ts +3 -4
  155. package/src/rewind/__tests__/rewind-command-module.test.ts +40 -0
  156. package/src/rewind/rewind-command-module.ts +2 -1
  157. package/src/rewind/rewind-command.ts +73 -6
  158. package/src/sandbox/__tests__/sandbox-command.test.ts +80 -0
  159. package/src/sandbox/index.ts +2 -0
  160. package/src/sandbox/sandbox-command-module.ts +56 -0
  161. package/src/sandbox/sandbox-command.ts +94 -0
  162. package/src/schedule/__tests__/loop-command-functional.test.ts +386 -0
  163. package/src/schedule/__tests__/loop-command.test.ts +360 -0
  164. package/src/schedule/__tests__/loop-jitter.test.ts +25 -0
  165. package/src/schedule/__tests__/monitor-model-permission-functional.test.ts +126 -0
  166. package/src/schedule/__tests__/schedule-command.test.ts +143 -3
  167. package/src/schedule/__tests__/schedule-redos.test.ts +81 -0
  168. package/src/schedule/index.ts +3 -0
  169. package/src/schedule/loop-command.ts +333 -0
  170. package/src/schedule/loop-jitter.ts +41 -0
  171. package/src/schedule/schedule-command-module.ts +92 -14
  172. package/src/schedule/schedule-command.ts +98 -9
  173. package/src/schedule/schedule-spec-parser.ts +5 -1
  174. package/src/session/__tests__/rename-host-persistence.test.ts +72 -0
  175. package/src/session/__tests__/session-command-module.test.ts +210 -45
  176. package/src/session/session-command-module.ts +68 -37
  177. package/src/session/session-command.ts +147 -80
  178. package/src/settings/settings-command-module.ts +3 -2
  179. package/src/shell/__tests__/resolve-shell-host.test.ts +11 -0
  180. package/src/shell/__tests__/shell-command-functional.test.ts +23 -1
  181. package/src/shell/resolve-shell.ts +2 -2
  182. package/src/shell/shell-command-module.ts +6 -5
  183. package/src/shell/shell-command.ts +9 -5
  184. package/src/skills/__tests__/skills-command-module.test.ts +69 -28
  185. package/src/skills/index.ts +1 -0
  186. package/src/skills/skills-command-module.ts +22 -5
  187. package/src/skills/skills-command.ts +14 -13
  188. package/src/statusline/__tests__/statusline-command-module.test.ts +5 -4
  189. package/src/statusline/statusline-command-module.ts +2 -1
  190. package/src/statusline/statusline-command.ts +4 -4
  191. package/src/theme/__tests__/theme-command.test.ts +204 -0
  192. package/src/theme/index.ts +12 -0
  193. package/src/theme/theme-command-module.ts +211 -0
  194. package/src/user-local/__tests__/user-local-command.test.ts +145 -110
  195. package/src/user-local/user-local-command-constants.ts +1 -1
  196. package/src/user-local/user-local-command-module.ts +6 -5
  197. package/src/user-local/user-local-command.ts +12 -6
  198. package/src/user-local/user-local-memory-command.ts +17 -7
  199. package/src/plugins/default-plugin-command-adapter.ts +0 -164
  200. package/src/plugins/default-plugin-command-source-loader.ts +0 -31
@@ -7,15 +7,18 @@ import {
7
7
  } from '@robota-sdk/agent-framework';
8
8
 
9
9
  import {
10
+ CD_COMMAND_DESCRIPTION,
11
+ executeCdCommand,
10
12
  executeClearCommand,
11
13
  executeCostCommand,
12
14
  executeRenameCommand,
13
15
  executeResumeCommand,
14
16
  executeValidateSessionCommand,
15
17
  } from './session-command.js';
18
+ import { createSystemCommandFromEntry } from '../command-module-utils.js';
16
19
 
17
20
  import type { ICommandModule, ISystemCommand } from '@robota-sdk/agent-framework';
18
- import type { ICommand, ICommandSource } from '@robota-sdk/agent-interface-transport';
21
+ import type { ICommand, ICommandSource } from '@robota-sdk/agent-interface-command';
19
22
 
20
23
  export function createClearCommandEntry(): ICommand {
21
24
  return {
@@ -23,7 +26,9 @@ export function createClearCommandEntry(): ICommand {
23
26
  displayName: 'Clear History',
24
27
  description: CLEAR_COMMAND_DESCRIPTION,
25
28
  source: 'session',
29
+ // User-only: wiping the conversation is the user's decision.
26
30
  modelInvocable: false,
31
+ userInvocable: true,
27
32
  };
28
33
  }
29
34
 
@@ -32,8 +37,24 @@ export function createRenameCommandEntry(): ICommand {
32
37
  name: 'rename',
33
38
  displayName: 'Rename Session',
34
39
  description: RENAME_COMMAND_DESCRIPTION,
40
+ argumentHint: '<name>',
41
+ source: 'session',
42
+ // User-only: the session's name is the user's label; UI-only.
43
+ modelInvocable: false,
44
+ userInvocable: true,
45
+ };
46
+ }
47
+
48
+ export function createCdCommandEntry(): ICommand {
49
+ return {
50
+ name: 'cd',
51
+ displayName: 'Change Directory',
52
+ description: CD_COMMAND_DESCRIPTION,
53
+ argumentHint: '<directory>',
35
54
  source: 'session',
55
+ // A move re-roots what every later tool call may touch; only the user decides it.
36
56
  modelInvocable: false,
57
+ userInvocable: true,
37
58
  };
38
59
  }
39
60
 
@@ -43,7 +64,9 @@ export function createResumeCommandEntry(): ICommand {
43
64
  displayName: 'Resume Session',
44
65
  description: RESUME_COMMAND_DESCRIPTION,
45
66
  source: 'session',
67
+ // User-only: switching to another session is the user's decision.
46
68
  modelInvocable: false,
69
+ userInvocable: true,
47
70
  };
48
71
  }
49
72
 
@@ -52,8 +75,28 @@ export function createCostCommandEntry(): ICommand {
52
75
  name: 'cost',
53
76
  displayName: 'Session Cost',
54
77
  description: COST_COMMAND_DESCRIPTION,
78
+ // Model-invocable for the read-only report only: the model can weigh cost before an expensive
79
+ // step. The monthly budget is the user's spending decision, so `budget` stays user-only.
80
+ modelDescription:
81
+ 'Report this session’s token usage and estimated cost. Use it before an expensive step (a ' +
82
+ 'large refactor, many subagents) or when the user asks what the session has cost. Returns ' +
83
+ 'the message count, input/output token totals and the estimated USD cost, with the remaining ' +
84
+ 'monthly budget when one is set. Setting or clearing the budget is the user’s decision: suggest `/cost budget <amount>`.',
85
+ argumentHint: '[budget [<amount>|clear]]',
86
+ // The bare `/cost` is the report, so choosing it from a menu runs it.
87
+ runsBare: true,
55
88
  source: 'session',
56
- modelInvocable: false,
89
+ modelInvocable: true,
90
+ userInvocable: true,
91
+ subcommands: [
92
+ {
93
+ name: 'budget',
94
+ description: 'Show, set, or clear the monthly budget',
95
+ argumentHint: '[<amount>|clear]',
96
+ source: 'session',
97
+ modelInvocable: false,
98
+ },
99
+ ],
57
100
  };
58
101
  }
59
102
 
@@ -63,78 +106,64 @@ export function createValidateSessionCommandEntry(): ICommand {
63
106
  displayName: 'Validate Session',
64
107
  description: VALIDATE_SESSION_COMMAND_DESCRIPTION,
65
108
  source: 'session',
109
+ // User-only: a diagnostic of the replay log for the user; nothing the model acts on.
66
110
  modelInvocable: false,
111
+ userInvocable: true,
67
112
  };
68
113
  }
69
114
 
70
115
  function createClearSystemCommand(): ISystemCommand {
71
116
  const entry = createClearCommandEntry();
72
- return {
73
- name: entry.name,
74
- displayName: entry.displayName,
75
- description: entry.description,
117
+ return createSystemCommandFromEntry(entry, {
76
118
  requiresPermission: false,
77
- userInvocable: true,
78
- modelInvocable: false,
79
119
  lifecycle: 'inline',
80
120
  execute: executeClearCommand,
81
- };
121
+ });
82
122
  }
83
123
 
84
124
  function createRenameSystemCommand(): ISystemCommand {
85
125
  const entry = createRenameCommandEntry();
86
- return {
87
- name: entry.name,
88
- displayName: entry.displayName,
89
- description: entry.description,
126
+ return createSystemCommandFromEntry(entry, {
90
127
  requiresPermission: false,
91
- userInvocable: true,
92
- modelInvocable: false,
93
128
  lifecycle: 'inline',
94
129
  execute: executeRenameCommand,
95
- };
130
+ });
131
+ }
132
+
133
+ function createCdSystemCommand(): ISystemCommand {
134
+ const entry = createCdCommandEntry();
135
+ return createSystemCommandFromEntry(entry, {
136
+ requiresPermission: false,
137
+ lifecycle: 'inline',
138
+ execute: executeCdCommand,
139
+ });
96
140
  }
97
141
 
98
142
  function createResumeSystemCommand(): ISystemCommand {
99
143
  const entry = createResumeCommandEntry();
100
- return {
101
- name: entry.name,
102
- displayName: entry.displayName,
103
- description: entry.description,
144
+ return createSystemCommandFromEntry(entry, {
104
145
  requiresPermission: false,
105
- userInvocable: true,
106
- modelInvocable: false,
107
146
  lifecycle: 'inline',
108
147
  execute: executeResumeCommand,
109
- };
148
+ });
110
149
  }
111
150
 
112
151
  function createCostSystemCommand(): ISystemCommand {
113
152
  const entry = createCostCommandEntry();
114
- return {
115
- name: entry.name,
116
- displayName: entry.displayName,
117
- description: entry.description,
153
+ return createSystemCommandFromEntry(entry, {
118
154
  requiresPermission: false,
119
- userInvocable: true,
120
- modelInvocable: false,
121
155
  lifecycle: 'inline',
122
156
  execute: executeCostCommand,
123
- };
157
+ });
124
158
  }
125
159
 
126
160
  function createValidateSessionSystemCommand(): ISystemCommand {
127
161
  const entry = createValidateSessionCommandEntry();
128
- return {
129
- name: entry.name,
130
- displayName: entry.displayName,
131
- description: entry.description,
162
+ return createSystemCommandFromEntry(entry, {
132
163
  requiresPermission: false,
133
- userInvocable: true,
134
- modelInvocable: false,
135
164
  lifecycle: 'inline',
136
165
  execute: executeValidateSessionCommand,
137
- };
166
+ });
138
167
  }
139
168
 
140
169
  export class SessionCommandSource implements ICommandSource {
@@ -144,6 +173,7 @@ export class SessionCommandSource implements ICommandSource {
144
173
  return [
145
174
  createClearCommandEntry(),
146
175
  createRenameCommandEntry(),
176
+ createCdCommandEntry(),
147
177
  createResumeCommandEntry(),
148
178
  createCostCommandEntry(),
149
179
  createValidateSessionCommandEntry(),
@@ -158,6 +188,7 @@ export function createSessionCommandModule(): ICommandModule {
158
188
  systemCommands: [
159
189
  createClearSystemCommand(),
160
190
  createRenameSystemCommand(),
191
+ createCdSystemCommand(),
161
192
  createResumeSystemCommand(),
162
193
  createCostSystemCommand(),
163
194
  createValidateSessionSystemCommand(),
@@ -1,12 +1,9 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
- import { join } from 'node:path';
3
-
4
1
  import { confirmAction, isConfirmed } from '@robota-sdk/agent-core';
5
2
  import {
6
3
  RENAME_COMMAND_USAGE,
7
4
  clearConversationHistory,
8
- createSessionPickerRequestedEffect,
9
- createSessionRenamedEffect,
5
+ createShowSessionPickerIntent,
6
+ createSessionRenameHostAction,
10
7
  formatCommandSessionReplayValidationReport,
11
8
  parseSessionNameArgument,
12
9
  readCommandSessionInfo,
@@ -15,17 +12,39 @@ import {
15
12
 
16
13
  import { calculateCost, formatTokens, formatUsd } from './model-pricing.js';
17
14
 
18
- import type { ICommandHostContext } from '@robota-sdk/agent-framework';
19
- import type { ICommandResult } from '@robota-sdk/agent-interface-transport';
15
+ import type {
16
+ ICommandCostBudgetAdapter,
17
+ ICommandHostAdapterAccess,
18
+ ICommandHostNoCapability,
19
+ ICommandHostSessionAccess,
20
+ ICommandHostUserInteraction,
21
+ ICommandHostWorkspace,
22
+ ISessionUsageRecord,
23
+ } from '@robota-sdk/agent-framework';
24
+ import type { ICommandResult } from '@robota-sdk/agent-interface-command';
20
25
 
21
26
  export const CLEAR_COMMAND_MESSAGE = 'Conversation cleared.';
22
27
 
28
+ export const CD_COMMAND_DESCRIPTION = 'Move this session to another working directory';
29
+ export const CD_COMMAND_USAGE = 'Usage: /cd <directory>';
30
+
31
+ /**
32
+ * `/cd <directory>` (issue #3081) requests the host-executed move. The checks — no turn running, no
33
+ * live background task, the target exists, no `Cd(...)` deny rule — run where the session state is,
34
+ * when the host applies the action.
35
+ */
36
+ export function executeCdCommand(_context: ICommandHostNoCapability, args: string): ICommandResult {
37
+ const path = args.trim();
38
+ if (path === '') return { success: false, message: CD_COMMAND_USAGE };
39
+ return { success: true, message: '', hostActions: [{ type: 'workspace-move', path }] };
40
+ }
41
+
23
42
  export async function executeClearCommand(
24
- context: ICommandHostContext,
43
+ context: ICommandHostSessionAccess & ICommandHostUserInteraction,
25
44
  _args: string,
26
45
  ): Promise<ICommandResult> {
27
46
  // Confirm only when an interactive renderer is attached; with no human the explicit /clear proceeds.
28
- const ui = context.getUserInteraction?.();
47
+ const ui = context.getUserInteraction();
29
48
  if (ui) {
30
49
  const response = await ui.ask(confirmAction('clear', 'Clear conversation history?'));
31
50
  if (!isConfirmed(response)) {
@@ -33,15 +52,16 @@ export async function executeClearCommand(
33
52
  }
34
53
  }
35
54
 
55
+ // CMD-004 Stage E: the clear mutation runs host-side and the session broadcasts `history_cleared`
56
+ // to every attached surface — the result carries no separate notification.
36
57
  clearConversationHistory(context);
37
- return {
38
- success: true,
39
- message: CLEAR_COMMAND_MESSAGE,
40
- effects: [{ type: 'conversation-history-cleared' }],
41
- };
58
+ return { success: true, message: CLEAR_COMMAND_MESSAGE };
42
59
  }
43
60
 
44
- export function executeRenameCommand(_context: ICommandHostContext, args: string): ICommandResult {
61
+ export function executeRenameCommand(
62
+ _context: ICommandHostNoCapability,
63
+ args: string,
64
+ ): ICommandResult {
45
65
  const name = parseSessionNameArgument(args);
46
66
  if (name === undefined) {
47
67
  return { success: false, message: RENAME_COMMAND_USAGE };
@@ -51,64 +71,95 @@ export function executeRenameCommand(_context: ICommandHostContext, args: string
51
71
  success: true,
52
72
  message: `Session renamed to "${name}".`,
53
73
  data: { name },
54
- effects: [createSessionRenamedEffect(name)],
74
+ hostActions: [createSessionRenameHostAction(name)],
55
75
  };
56
76
  }
57
77
 
58
- export function executeResumeCommand(_context: ICommandHostContext, _args: string): ICommandResult {
78
+ export function executeResumeCommand(): ICommandResult {
59
79
  return {
60
80
  success: true,
61
81
  message: 'Opening session picker...',
62
82
  data: { triggerResumePicker: true },
63
- effects: [createSessionPickerRequestedEffect()],
83
+ uiIntents: [createShowSessionPickerIntent()],
64
84
  };
65
85
  }
66
86
 
67
- const BUDGET_FILE = '.robota/budget.json';
87
+ type TCostCommandContext = ICommandHostSessionAccess &
88
+ ICommandHostWorkspace &
89
+ ICommandHostAdapterAccess;
68
90
 
69
- interface IBudgetConfig {
70
- monthly: number;
91
+ /**
92
+ * CMD-007 (issue #2058): the budget lives behind `ICommandCostBudgetAdapter`, composed by the shell.
93
+ * This package owns no path literal and no filesystem call for it any more — atomic/symlink-safe
94
+ * writing is the adapter's policy (agent-cli's file adapter keeps the O_NOFOLLOW guard).
95
+ */
96
+ function costBudgetAdapter(context: TCostCommandContext): ICommandCostBudgetAdapter | undefined {
97
+ return context.getCommandHostAdapters?.().costBudget;
71
98
  }
72
99
 
73
- function readBudget(cwd: string): IBudgetConfig | undefined {
74
- const file = join(cwd, BUDGET_FILE);
75
- if (!existsSync(file)) return undefined;
76
- let raw: string;
77
- try {
78
- raw = readFileSync(file, 'utf-8');
79
- } catch {
80
- // allow-fallback: budget file read failure disables feature gracefully
81
- return undefined;
82
- }
83
- try {
84
- return JSON.parse(raw) as IBudgetConfig;
85
- } catch {
86
- // allow-fallback: malformed budget JSON treated as no budget set
87
- return undefined;
88
- }
100
+ const BUDGET_UNAVAILABLE_MESSAGE =
101
+ 'Budget persistence is not available on this host (no cost-budget adapter was composed).';
102
+
103
+ /** What to tell the operator when the adapter refused the write; the adapter names the cause. */
104
+ function describeBudgetWriteFailure(error: unknown): string {
105
+ return `Could not write the budget: ${error instanceof Error ? error.message : String(error)}`;
89
106
  }
90
107
 
91
- function writeBudget(cwd: string, config: IBudgetConfig): void {
92
- const dir = join(cwd, '.robota');
93
- mkdirSync(dir, { recursive: true });
94
- writeFileSync(join(cwd, BUDGET_FILE), JSON.stringify(config, null, 2));
108
+ interface ICostTotals {
109
+ inputTokens: number;
110
+ outputTokens: number;
111
+ /** Absent when nothing could be priced. */
112
+ costUsd?: number;
113
+ /** Some usage could not be priced, so the cost is a lower bound. */
114
+ partial: boolean;
115
+ /** The models the cost was priced on. */
116
+ models: string[];
95
117
  }
96
118
 
97
- function clearBudget(cwd: string): void {
98
- const file = join(cwd, BUDGET_FILE);
99
- if (existsSync(file)) {
100
- writeFileSync(file, '{}');
119
+ /**
120
+ * Total the session's usage records. A record priced on its own model (the advisor's) keeps that
121
+ * price; a main-thread record without one is priced on the session's model. A consulted model whose
122
+ * price is unknown is left unpriced rather than priced as the main model.
123
+ */
124
+ function totalSessionUsage(
125
+ records: readonly ISessionUsageRecord[],
126
+ modelId: string | undefined,
127
+ ): ICostTotals | undefined {
128
+ if (records.length === 0) return undefined;
129
+ const totals: ICostTotals = { inputTokens: 0, outputTokens: 0, partial: false, models: [] };
130
+ const models = new Set<string>();
131
+ let cost = 0;
132
+ let priced = false;
133
+ for (const record of records) {
134
+ totals.inputTokens += record.promptTokens;
135
+ totals.outputTokens += record.completionTokens;
136
+ const consulted = record.source?.scope === 'tool';
137
+ const recordCost =
138
+ record.costUsd ??
139
+ (!consulted && modelId
140
+ ? calculateCost(modelId, record.promptTokens, record.completionTokens)
141
+ : undefined);
142
+ if (recordCost === undefined) {
143
+ totals.partial = true;
144
+ continue;
145
+ }
146
+ cost += recordCost;
147
+ priced = true;
148
+ models.add(consulted ? (record.source?.label ?? 'another model') : (modelId ?? 'main model'));
101
149
  }
150
+ if (priced) totals.costUsd = cost;
151
+ totals.models = [...models];
152
+ return totals;
102
153
  }
103
154
 
104
- function buildCostOutput(context: ICommandHostContext): {
155
+ function buildCostOutput(context: TCostCommandContext): {
105
156
  lines: string[];
106
157
  data: Record<string, unknown>;
107
158
  } {
108
159
  const session = context.getSession();
109
160
  const sessionInfo = readCommandSessionInfo(context);
110
- const tokenUsage = session.getSessionTokenUsage?.();
111
- const modelId = session.getModelId?.();
161
+ const modelId = session.getModelId();
162
+ const totals = totalSessionUsage(context.getSessionUsage(), modelId);
112
163
  const lines: string[] = [
113
164
  `Session: ${sessionInfo.sessionId}`,
114
165
  `Messages: ${sessionInfo.messageCount}`,
@@ -118,29 +169,32 @@ function buildCostOutput(context: ICommandHostContext): {
118
169
  messageCount: sessionInfo.messageCount,
119
170
  };
120
171
 
121
- if (tokenUsage) {
172
+ if (totals) {
122
173
  lines.push(
123
- `Tokens: ${formatTokens(tokenUsage.inputTokens)} input / ${formatTokens(tokenUsage.outputTokens)} output`,
174
+ `Tokens: ${formatTokens(totals.inputTokens)} input / ${formatTokens(totals.outputTokens)} output`,
124
175
  );
125
- data.inputTokens = tokenUsage.inputTokens;
126
- data.outputTokens = tokenUsage.outputTokens;
127
-
128
- if (modelId) {
129
- const cost = calculateCost(modelId, tokenUsage.inputTokens, tokenUsage.outputTokens);
130
- if (cost !== undefined) {
131
- lines.push(`Cost: ${formatUsd(cost)} (${modelId})`);
132
- data.estimatedCostUsd = cost;
133
-
134
- const budget = readBudget(context.getCwd());
135
- if (budget?.monthly) {
136
- const remaining = budget.monthly - cost;
137
- const pct = Math.min(100, Math.round((cost / budget.monthly) * 100));
138
- lines.push(
139
- `Budget: ${formatUsd(remaining)} remaining of ${formatUsd(budget.monthly)}/mo (${pct}% used)`,
140
- );
141
- data.budgetMonthly = budget.monthly;
142
- data.budgetRemainingUsd = remaining;
143
- }
176
+ data.inputTokens = totals.inputTokens;
177
+ data.outputTokens = totals.outputTokens;
178
+
179
+ const cost = totals.costUsd;
180
+ if (cost !== undefined) {
181
+ const priced =
182
+ totals.models.length > 1 ? `mixed: ${totals.models.join(', ')}` : (totals.models[0] ?? '');
183
+ const note = totals.partial ? '; some usage could not be priced' : '';
184
+ lines.push(`Cost: ${formatUsd(cost)} (${priced}${note})`);
185
+ data.estimatedCostUsd = cost;
186
+ data.costModels = totals.models;
187
+ if (totals.partial) data.costPartial = true;
188
+
189
+ const budget = costBudgetAdapter(context)?.read();
190
+ if (budget?.monthly) {
191
+ const remaining = budget.monthly - cost;
192
+ const pct = Math.min(100, Math.round((cost / budget.monthly) * 100));
193
+ lines.push(
194
+ `Budget: ${formatUsd(remaining)} remaining of ${formatUsd(budget.monthly)}/mo (${pct}% used)`,
195
+ );
196
+ data.budgetMonthly = budget.monthly;
197
+ data.budgetRemainingUsd = remaining;
144
198
  }
145
199
  }
146
200
  } else {
@@ -150,19 +204,34 @@ function buildCostOutput(context: ICommandHostContext): {
150
204
  return { lines, data };
151
205
  }
152
206
 
153
- export function executeCostCommand(context: ICommandHostContext, args: string): ICommandResult {
207
+ export function executeCostCommand(context: TCostCommandContext, args: string): ICommandResult {
154
208
  const trimmed = args.trim();
155
209
 
156
210
  if (trimmed.startsWith('budget')) {
157
211
  const budgetArg = trimmed.slice('budget'.length).trim();
212
+ // Argument shape is judged before the host is asked for persistence: a malformed amount is a
213
+ // usage error on every host, not a symptom of the adapter that would have stored it.
214
+ const amount = budgetArg === 'clear' || budgetArg === '' ? undefined : parseFloat(budgetArg);
215
+ if (amount !== undefined && (!Number.isFinite(amount) || amount <= 0)) {
216
+ return {
217
+ success: false,
218
+ message: 'Usage: /cost budget <amount> (e.g. /cost budget 5.00)',
219
+ };
220
+ }
221
+ const adapter = costBudgetAdapter(context);
222
+ if (adapter === undefined) return { success: false, message: BUDGET_UNAVAILABLE_MESSAGE };
158
223
 
159
224
  if (budgetArg === 'clear') {
160
- clearBudget(context.getCwd());
225
+ try {
226
+ adapter.clear();
227
+ } catch (error) {
228
+ return { success: false, message: describeBudgetWriteFailure(error) };
229
+ }
161
230
  return { success: true, message: 'Monthly budget cleared.' };
162
231
  }
163
232
 
164
233
  if (budgetArg === '') {
165
- const current = readBudget(context.getCwd());
234
+ const current = adapter.read();
166
235
  if (!current?.monthly) {
167
236
  return {
168
237
  success: true,
@@ -172,14 +241,12 @@ export function executeCostCommand(context: ICommandHostContext, args: string):
172
241
  return { success: true, message: `Monthly budget: ${formatUsd(current.monthly)}` };
173
242
  }
174
243
 
175
- const amount = parseFloat(budgetArg);
176
- if (!Number.isFinite(amount) || amount <= 0) {
177
- return {
178
- success: false,
179
- message: 'Usage: /cost budget <amount> (e.g. /cost budget 5.00)',
180
- };
244
+ if (amount === undefined) return { success: false, message: BUDGET_UNAVAILABLE_MESSAGE };
245
+ try {
246
+ adapter.write({ monthly: amount });
247
+ } catch (error) {
248
+ return { success: false, message: describeBudgetWriteFailure(error) };
181
249
  }
182
- writeBudget(context.getCwd(), { monthly: amount });
183
250
  return { success: true, message: `Monthly budget set to ${formatUsd(amount)}.` };
184
251
  }
185
252
 
@@ -188,7 +255,7 @@ export function executeCostCommand(context: ICommandHostContext, args: string):
188
255
  }
189
256
 
190
257
  export function executeValidateSessionCommand(
191
- context: ICommandHostContext,
258
+ context: ICommandHostSessionAccess & ICommandHostWorkspace,
192
259
  _args: string,
193
260
  ): ICommandResult {
194
261
  const report = validateCommandSessionReplayLog(context);
@@ -1,5 +1,5 @@
1
1
  import type { ICommandModule, ISystemCommand } from '@robota-sdk/agent-framework';
2
- import type { ICommand, ICommandSource } from '@robota-sdk/agent-interface-transport';
2
+ import type { ICommand, ICommandSource } from '@robota-sdk/agent-interface-command';
3
3
 
4
4
  export function createSettingsCommandEntry(): ICommand {
5
5
  return {
@@ -7,6 +7,7 @@ export function createSettingsCommandEntry(): ICommand {
7
7
  displayName: 'Settings',
8
8
  description: 'Open transport settings — enable/disable transports and configure options',
9
9
  source: 'settings',
10
+ // User-only: transport settings screen; UI-only.
10
11
  modelInvocable: false,
11
12
  };
12
13
  }
@@ -24,7 +25,7 @@ function createSettingsSystemCommand(): ISystemCommand {
24
25
  execute: async () => ({
25
26
  success: true,
26
27
  message: 'Opening settings...',
27
- effects: [{ type: 'settings-tui-requested' as const }],
28
+ uiIntents: [{ type: 'show-settings' as const }],
28
29
  }),
29
30
  };
30
31
  }
@@ -0,0 +1,11 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { resolveShell } from '../resolve-shell.js';
4
+
5
+ describe('interactive shell host selection', () => {
6
+ it('keeps the executable and argument family together', () => {
7
+ const shell = resolveShell('C:\\Windows\\System32\\cmd.exe');
8
+ expect(shell.command).toBe('C:\\Windows\\System32\\cmd.exe');
9
+ expect(shell.commandArgs('echo ok')).toEqual(['/d', '/s', '/c', 'echo ok']);
10
+ });
11
+ });
@@ -12,7 +12,7 @@ import { scriptedSession, type ScriptedSessionHarness } from '@robota-sdk/agent-
12
12
 
13
13
  import { createShellCommandModule } from '../shell-command-module.js';
14
14
 
15
- import type { ITerminalHandoff } from '@robota-sdk/agent-interface-transport';
15
+ import type { ITerminalHandoff } from '@robota-sdk/agent-interface-session';
16
16
 
17
17
  const TEST_TIMEOUT = 20_000;
18
18
 
@@ -39,6 +39,28 @@ afterEach(async () => {
39
39
  });
40
40
 
41
41
  describe('/shell command (framework functional)', () => {
42
+ it(
43
+ 'uses the host-selected executable captured by its module',
44
+ async () => {
45
+ if (process.platform === 'win32') return;
46
+ const previousShell = process.env['SHELL'];
47
+ process.env['SHELL'] = '/bin/false';
48
+ try {
49
+ h = scriptedSession({
50
+ turns: [{ text: 'unused' }],
51
+ terminalHandoff: fakeHandoff(true),
52
+ commandModules: [createShellCommandModule('/bin/bash')],
53
+ });
54
+ const result = await h.command('shell', 'exit 0');
55
+ expect(result?.success).toBe(true);
56
+ } finally {
57
+ if (previousShell === undefined) delete process.env['SHELL'];
58
+ else process.env['SHELL'] = previousShell;
59
+ }
60
+ },
61
+ TEST_TIMEOUT,
62
+ );
63
+
42
64
  it(
43
65
  'runs a one-shot command through the handoff and returns exit code 0',
44
66
  async () => {
@@ -15,8 +15,8 @@ export interface IResolvedShell {
15
15
  }
16
16
 
17
17
  /** Resolve the interactive shell for the current platform via the agent-core SSOT resolver. */
18
- export function resolveShell(): IResolvedShell {
19
- const shell = resolvePlatformShell();
18
+ export function resolveShell(executable?: string): IResolvedShell {
19
+ const shell = resolvePlatformShell({ executable });
20
20
  return {
21
21
  command: shell.command,
22
22
  interactiveArgs: shell.interactiveArgs,
@@ -4,7 +4,7 @@
4
4
  import { executeShellCommand, SHELL_COMMAND_DESCRIPTION } from './shell-command.js';
5
5
 
6
6
  import type { ICommandModule, ISystemCommand } from '@robota-sdk/agent-framework';
7
- import type { ICommand, ICommandSource } from '@robota-sdk/agent-interface-transport';
7
+ import type { ICommand, ICommandSource } from '@robota-sdk/agent-interface-command';
8
8
 
9
9
  export function createShellCommandEntry(): ICommand {
10
10
  return {
@@ -12,11 +12,12 @@ export function createShellCommandEntry(): ICommand {
12
12
  displayName: 'Shell',
13
13
  description: SHELL_COMMAND_DESCRIPTION,
14
14
  source: 'shell',
15
+ // User-only: the user's own shell passthrough; the model has its permission-gated shell tool.
15
16
  modelInvocable: false,
16
17
  };
17
18
  }
18
19
 
19
- function createShellSystemCommand(): ISystemCommand {
20
+ function createShellSystemCommand(executable?: string): ISystemCommand {
20
21
  const entry = createShellCommandEntry();
21
22
  return {
22
23
  name: entry.name,
@@ -26,7 +27,7 @@ function createShellSystemCommand(): ISystemCommand {
26
27
  userInvocable: true,
27
28
  modelInvocable: false,
28
29
  lifecycle: 'inline',
29
- execute: executeShellCommand,
30
+ execute: (context, args) => executeShellCommand(context, args, executable),
30
31
  };
31
32
  }
32
33
 
@@ -38,10 +39,10 @@ export class ShellCommandSource implements ICommandSource {
38
39
  }
39
40
  }
40
41
 
41
- export function createShellCommandModule(): ICommandModule {
42
+ export function createShellCommandModule(executable?: string): ICommandModule {
42
43
  return {
43
44
  name: 'agent-command-shell',
44
45
  commandSources: [new ShellCommandSource()],
45
- systemCommands: [createShellSystemCommand()],
46
+ systemCommands: [createShellSystemCommand(executable)],
46
47
  };
47
48
  }