@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
@@ -0,0 +1,241 @@
1
+ /**
2
+ * PEER-004 (#1863) — `/peers` is the surface that makes local discovery observable.
3
+ *
4
+ * Discovery landed as two leaves — a guarded rendezvous directory and a registry whose liveness is
5
+ * settled by pid AND process start time — and measured on this tree before this command existed, no
6
+ * source outside those modules and their tests called either one. A discovery layer nobody can look
7
+ * at is one nobody can catch being wrong, so what these cases pin is the READING: what an operator
8
+ * is told, and in particular what they are told when the answer is "I could not determine".
9
+ */
10
+
11
+ import { describe, expect, it } from 'vitest';
12
+
13
+ import { executePeersCommand } from '../peers-command.js';
14
+
15
+ import type { ICommandHostAdapterAccess, ICommandHostAdapters } from '@robota-sdk/agent-framework';
16
+
17
+ type TPeerSummary = ReturnType<NonNullable<ICommandHostAdapters['localPeers']>['list']>[number];
18
+
19
+ const OWN = 'session-self';
20
+
21
+ function hostWithPeers(peers: readonly TPeerSummary[]): ICommandHostAdapterAccess {
22
+ return {
23
+ getCommandHostAdapters: () => ({
24
+ localPeers: { list: () => peers, ownSessionId: () => OWN },
25
+ }),
26
+ } as ICommandHostAdapterAccess;
27
+ }
28
+
29
+ describe('what the operator is told', () => {
30
+ it('lists another live session, and marks which row is this one', async () => {
31
+ const result = await executePeersCommand(
32
+ hostWithPeers([
33
+ { sessionId: OWN, liveness: 'alive' },
34
+ { sessionId: 'session-other', name: 'review', liveness: 'alive' },
35
+ ]),
36
+ );
37
+ expect(result.success).toBe(true);
38
+ expect(result.message).toContain('session-other');
39
+ expect(result.message).toContain('review');
40
+ expect(result.message).toMatch(/session-self.*\(this session\)/);
41
+ });
42
+
43
+ it('says so plainly when this session is the only one', async () => {
44
+ // Not an empty list rendered as a header with nothing under it. "No other live session" is the
45
+ // answer, and it is different from "discovery is unavailable" below.
46
+ const result = await executePeersCommand(
47
+ hostWithPeers([{ sessionId: OWN, liveness: 'alive' }]),
48
+ );
49
+ expect(result.message).toContain('No other live session');
50
+ expect(result.success).toBe(true);
51
+ });
52
+
53
+ it('prints `unknown` liveness rather than rounding it to alive', async () => {
54
+ // A host that cannot read process start times answers `unknown` for every peer. A reader who is
55
+ // not told that would take a stale entry for a live session — which is the exact guess the
56
+ // registry refuses to make, and it must not be reintroduced by the surface that displays it.
57
+ const result = await executePeersCommand(
58
+ hostWithPeers([
59
+ { sessionId: OWN, liveness: 'alive' },
60
+ { sessionId: 'session-other', liveness: 'unknown' },
61
+ ]),
62
+ );
63
+ expect(result.message).toContain('liveness unknown');
64
+ });
65
+
66
+ it('shows observed activity and marks missing activity unknown', async () => {
67
+ const result = await executePeersCommand(
68
+ hostWithPeers([
69
+ { sessionId: OWN, liveness: 'alive', status: 'idle' },
70
+ { sessionId: 'busy', liveness: 'alive', status: 'working' },
71
+ { sessionId: 'waiting', liveness: 'alive', status: 'needs-input' },
72
+ { sessionId: 'unobserved', liveness: 'unknown', status: 'unknown' },
73
+ ]),
74
+ );
75
+ expect(result.message).toMatch(/busy.*working/);
76
+ expect(result.message).toMatch(/waiting.*needs input/);
77
+ expect(result.message).toMatch(/unobserved.*status unknown/);
78
+ });
79
+
80
+ it('shows how each peer is related by workspace (#3101 B2)', async () => {
81
+ const result = await executePeersCommand(
82
+ hostWithPeers([
83
+ { sessionId: OWN, liveness: 'alive', workspaceRelation: 'same-worktree' },
84
+ { sessionId: 'here', liveness: 'alive', workspaceRelation: 'same-worktree' },
85
+ { sessionId: 'sibling', liveness: 'alive', workspaceRelation: 'same-repo' },
86
+ { sessionId: 'elsewhere', liveness: 'alive', workspaceRelation: 'different-repo' },
87
+ { sessionId: 'plain', liveness: 'alive', workspaceRelation: 'unknown' },
88
+ ]),
89
+ );
90
+ expect(result.message).toMatch(/here.*same worktree/);
91
+ expect(result.message).toMatch(/sibling.*same repo/);
92
+ expect(result.message).toMatch(/elsewhere.*different repo/);
93
+ expect(result.message).toMatch(/plain.*workspace unknown/);
94
+ });
95
+
96
+ it('reads the workspace-judged listing when the host offers one', async () => {
97
+ const result = await executePeersCommand({
98
+ getCommandHostAdapters: () => ({
99
+ localPeers: {
100
+ list: () => [
101
+ { sessionId: OWN, liveness: 'alive' },
102
+ { sessionId: 'sibling', liveness: 'alive' },
103
+ ],
104
+ listWithWorkspace: async () => [
105
+ { sessionId: OWN, liveness: 'alive' },
106
+ { sessionId: 'sibling', liveness: 'alive', workspaceRelation: 'same-repo' },
107
+ ],
108
+ ownSessionId: () => OWN,
109
+ },
110
+ }),
111
+ } as ICommandHostAdapterAccess);
112
+ expect(result.message).toMatch(/sibling.*same repo/);
113
+ });
114
+
115
+ it('says a mismatched workspace claim was not believed', async () => {
116
+ const result = await executePeersCommand(
117
+ hostWithPeers([
118
+ { sessionId: OWN, liveness: 'alive' },
119
+ {
120
+ sessionId: 'liar',
121
+ liveness: 'alive',
122
+ workspaceRelation: 'unknown',
123
+ workspaceClaim: 'mismatched',
124
+ },
125
+ ]),
126
+ );
127
+ expect(result.message).toMatch(/liar.*workspace claim mismatched, not believed/);
128
+ expect(result.message).not.toMatch(/liar.*same (worktree|repo)/);
129
+ });
130
+
131
+ it('does not show a dead entry', async () => {
132
+ // A crashed session leaves its file behind. That is debris, not a peer, and offering it as one
133
+ // would make the operator address something that cannot answer.
134
+ const result = await executePeersCommand(
135
+ hostWithPeers([
136
+ { sessionId: OWN, liveness: 'alive' },
137
+ { sessionId: 'session-gone', liveness: 'dead' },
138
+ ]),
139
+ );
140
+ expect(result.message).not.toContain('session-gone');
141
+ expect(result.message).toContain('No other live session');
142
+ });
143
+ });
144
+
145
+ describe('when the host wires no discovery', () => {
146
+ it('says the feature is unavailable rather than reporting no peers', async () => {
147
+ // The two are different facts and the difference matters: "nobody is there" invites the operator
148
+ // to start a second session, and "this build cannot see them" does not.
149
+ const result = await executePeersCommand({
150
+ getCommandHostAdapters: () => ({}),
151
+ } as ICommandHostAdapterAccess);
152
+ expect(result.message).toContain('not available');
153
+ expect(result.message).not.toContain('No other live session');
154
+ });
155
+ });
156
+
157
+ describe('PEER-006 — /peers send', () => {
158
+ function hostWithSend(
159
+ send: (target: string, text: string) => Promise<{ state: string; reason?: string }>,
160
+ ): ICommandHostAdapterAccess {
161
+ return {
162
+ getCommandHostAdapters: () => ({
163
+ localPeers: {
164
+ list: () => [{ sessionId: 'other', liveness: 'alive' as const }],
165
+ ownSessionId: () => OWN,
166
+ send,
167
+ },
168
+ }),
169
+ } as unknown as ICommandHostAdapterAccess;
170
+ }
171
+
172
+ it('passes the whole message, not just its first word', async () => {
173
+ const calls: Array<[string, string]> = [];
174
+ await executePeersCommand(
175
+ hostWithSend(async (target, text) => {
176
+ calls.push([target, text]);
177
+ return { state: 'acknowledged' };
178
+ }),
179
+ 'send other look at the failing test in cli.ts',
180
+ );
181
+
182
+ expect(calls).toEqual([['other', 'look at the failing test in cli.ts']]);
183
+ });
184
+
185
+ it('reports `pending` as waiting, never as delivered', async () => {
186
+ const result = await executePeersCommand(
187
+ hostWithSend(async () => ({ state: 'pending' })),
188
+ 'send other hello',
189
+ );
190
+
191
+ // The operator has to be able to see the wait. "Delivered" here would hide a turn already
192
+ // running on the other side, which is the one thing they might want to act on.
193
+ expect(result.message).toContain('waiting');
194
+ expect(result.message).not.toContain('Delivered');
195
+ expect(result.success).toBe(true);
196
+ });
197
+
198
+ it('carries the refusal reason back to the operator', async () => {
199
+ const result = await executePeersCommand(
200
+ hostWithSend(async () => ({ state: 'refused', reason: 'no session ghost is announced' })),
201
+ 'send ghost hello',
202
+ );
203
+
204
+ expect(result.message).toContain('no session ghost is announced');
205
+ expect(result.success).toBe(false);
206
+ });
207
+
208
+ it('explains what it needs when the message is missing', async () => {
209
+ const result = await executePeersCommand(
210
+ hostWithSend(async () => ({ state: 'acknowledged' })),
211
+ 'send other',
212
+ );
213
+
214
+ expect(result.message).toContain('Usage: /peers send');
215
+ expect(result.success).toBe(false);
216
+ });
217
+
218
+ it('does not read a session id beginning with `send` as the subcommand', async () => {
219
+ // `startsWith('send')` would claim this as `/peers send`, leaving `er-1` as the target.
220
+ const result = await executePeersCommand(
221
+ hostWithSend(async () => ({ state: 'acknowledged' })),
222
+ 'sender-1',
223
+ );
224
+
225
+ expect(result.message).toContain('Live sessions:');
226
+ });
227
+
228
+ it('says so when the host can discover peers but not address them', async () => {
229
+ const result = await executePeersCommand(
230
+ {
231
+ getCommandHostAdapters: () => ({
232
+ localPeers: { list: () => [], ownSessionId: () => OWN },
233
+ }),
234
+ } as ICommandHostAdapterAccess,
235
+ 'send other hello',
236
+ );
237
+
238
+ expect(result.message).toContain('cannot address them');
239
+ expect(result.success).toBe(false);
240
+ });
241
+ });
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Only the module factory leaves this directory.
3
+ *
4
+ * `executePeersCommand`, `PeersCommandSource` and `createPeersCommandEntry` are how the module is
5
+ * BUILT, not how it is used: the command is registered by default, so nothing outside constructs one.
6
+ * Exporting them would have added four entries to this package's frozen undocumented-surface count —
7
+ * debt that may shrink and never grow — in exchange for a reach nobody asked for.
8
+ */
9
+ export { createPeersCommandModule } from './peers-command-module.js';
@@ -0,0 +1,47 @@
1
+ import { executePeersCommand } from './peers-command.js';
2
+
3
+ import type { ICommandModule, ISystemCommand } from '@robota-sdk/agent-framework';
4
+ import type { ICommand, ICommandSource } from '@robota-sdk/agent-interface-command';
5
+
6
+ export function createPeersCommandEntry(): ICommand {
7
+ return {
8
+ name: 'peers',
9
+ displayName: 'Peers',
10
+ description: 'List the other live sessions on this host, or send one a message',
11
+ source: 'peers',
12
+ // The model does not enumerate the operator's other sessions. Discovery is an operator-facing
13
+ // view of who is at the machine, which is a fact about the person and not about the task.
14
+ // User-only: sends messages into other sessions; crossing a session boundary is the user's call.
15
+ modelInvocable: false,
16
+ };
17
+ }
18
+
19
+ function createPeersSystemCommand(): ISystemCommand {
20
+ const entry = createPeersCommandEntry();
21
+ return {
22
+ name: entry.name,
23
+ displayName: entry.displayName,
24
+ description: entry.description,
25
+ requiresPermission: false,
26
+ userInvocable: true,
27
+ modelInvocable: false,
28
+ lifecycle: 'inline',
29
+ execute: (context, args) => executePeersCommand(context, args),
30
+ };
31
+ }
32
+
33
+ export class PeersCommandSource implements ICommandSource {
34
+ readonly name = 'peers';
35
+
36
+ getCommands(): ICommand[] {
37
+ return [createPeersCommandEntry()];
38
+ }
39
+ }
40
+
41
+ export function createPeersCommandModule(): ICommandModule {
42
+ return {
43
+ name: 'agent-command-peers',
44
+ commandSources: [new PeersCommandSource()],
45
+ systemCommands: [createPeersSystemCommand()],
46
+ };
47
+ }
@@ -0,0 +1,142 @@
1
+ import type { ICommandHostAdapterAccess, ICommandHostAdapters } from '@robota-sdk/agent-framework';
2
+ import type { ICommandResult } from '@robota-sdk/agent-interface-command';
3
+
4
+ /** One row as the port hands it over. Derived, so it cannot drift from the adapter it reads. */
5
+ type TPeerSummary = ReturnType<NonNullable<ICommandHostAdapters['localPeers']>['list']>[number];
6
+
7
+ /**
8
+ * `/peers` (PEER-004, #1863) — which other live sessions this one can address.
9
+ *
10
+ * Discovery landed as a leaf (a guarded rendezvous directory, an entry per live session, liveness
11
+ * settled by pid AND process start time) and nothing called it: measured on this tree, no file
12
+ * outside the module and its tests referenced `announcePeer` or `openLocalPeerRendezvous`. This
13
+ * command is the surface that makes it observable, which is also what makes it falsifiable — a
14
+ * discovery layer nobody can look at is one nobody can catch being wrong.
15
+ *
16
+ * The command reads an injected adapter and touches no filesystem, for the same reason it never
17
+ * constructs a transport: the guarded directory, its permissions and the liveness rule are
18
+ * composition-root concerns and must have one owner.
19
+ */
20
+
21
+ /** `dead` is not shown. An entry whose process is gone is debris, not a peer. */
22
+ function addressable(peer: TPeerSummary): boolean {
23
+ return peer.liveness !== 'dead';
24
+ }
25
+
26
+ function describe(peer: TPeerSummary, ownSessionId: string): string {
27
+ const self = peer.sessionId === ownSessionId ? ' (this session)' : '';
28
+ // `unknown` is printed, never rounded to `alive`. A host that cannot read process start times
29
+ // answers `unknown` for every peer, and a reader who is not told that would take a stale entry for
30
+ // a live session.
31
+ const liveness = peer.liveness === 'unknown' ? ' liveness unknown' : '';
32
+ const status = peer.status === 'needs-input' ? 'needs input' : (peer.status ?? 'unknown');
33
+ return ` ${peer.sessionId}${peer.name ? ` ${peer.name}` : ''} status ${status}${self ? '' : workspace(peer)}${liveness}${self}`;
34
+ }
35
+
36
+ /** The relation this session verified. A claim it could not confirm is named as such, not shown. */
37
+ function workspace(peer: TPeerSummary): string {
38
+ if (peer.workspaceClaim === 'mismatched') return ' workspace claim mismatched, not believed';
39
+ switch (peer.workspaceRelation) {
40
+ case undefined:
41
+ return '';
42
+ case 'unknown':
43
+ return ' workspace unknown';
44
+ default:
45
+ return ` ${peer.workspaceRelation.replace('-', ' ')}`;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * PEER-006 — `/peers send <session-id> <text>`.
51
+ *
52
+ * Split on the FIRST run of whitespace only: everything after the session id is the message, spaces
53
+ * and all. Splitting on every space would silently truncate any message with more than one word,
54
+ * which is nearly all of them.
55
+ */
56
+ function parseSend(args: string): { target: string; text: string } | undefined {
57
+ const trimmed = args.trim();
58
+ const at = trimmed.search(/\s/);
59
+ if (at === -1) return undefined;
60
+ const text = trimmed.slice(at).trim();
61
+ if (text === '') return undefined;
62
+ return { target: trimmed.slice(0, at), text };
63
+ }
64
+
65
+ /** A state the sender can act on, rendered as a sentence rather than a status word. */
66
+ function describeSend(result: { state: string; reason?: string }, target: string): ICommandResult {
67
+ const reason = result.reason !== undefined ? ` ${result.reason}` : '';
68
+ switch (result.state) {
69
+ case 'acknowledged':
70
+ case 'delivered':
71
+ return { message: `Delivered to ${target}.`, success: true };
72
+ case 'pending':
73
+ // NOT reported as delivered. The peer has it and has not run it — usually because a turn is
74
+ // already running there — and telling the operator it landed would hide a wait they can see.
75
+ return {
76
+ message: `${target} has the message; it is waiting behind work already running there.`,
77
+ success: true,
78
+ };
79
+ case 'duplicate':
80
+ return { message: `${target} had already seen that message.`, success: true };
81
+ default:
82
+ return { message: `Not delivered to ${target}.${reason}`, success: false };
83
+ }
84
+ }
85
+
86
+ async function executeSend(
87
+ adapter: NonNullable<ICommandHostAdapters['localPeers']>,
88
+ args: string,
89
+ ): Promise<ICommandResult> {
90
+ if (adapter.send === undefined) {
91
+ return {
92
+ message: 'This environment can discover other sessions but cannot address them.',
93
+ success: false,
94
+ };
95
+ }
96
+ const parsed = parseSend(args);
97
+ if (parsed === undefined) {
98
+ return {
99
+ message: 'Usage: /peers send <session-id> <message>. Run /peers for the session ids.',
100
+ success: false,
101
+ };
102
+ }
103
+ return describeSend(await adapter.send(parsed.target, parsed.text), parsed.target);
104
+ }
105
+
106
+ export async function executePeersCommand(
107
+ context: ICommandHostAdapterAccess,
108
+ args = '',
109
+ ): Promise<ICommandResult> {
110
+ const adapter = context.getCommandHostAdapters?.().localPeers;
111
+ if (!adapter) {
112
+ return { message: 'Local peer discovery is not available in this environment.', success: true };
113
+ }
114
+
115
+ // `send` must be a WHOLE word: `startsWith('send')` would also claim a session id beginning with
116
+ // those four letters, and a uuid that happens to start `send…` is not a subcommand.
117
+ const trimmed = args.trim();
118
+ const sendVerb = /^send(?=\s|$)/.exec(trimmed);
119
+ if (sendVerb !== null) return executeSend(adapter, trimmed.slice(sendVerb[0].length));
120
+
121
+ const own = adapter.ownSessionId();
122
+ // The workspace-judged listing when the host has one; it reads git, so only this view asks for it.
123
+ const peers = (
124
+ adapter.listWithWorkspace !== undefined ? await adapter.listWithWorkspace() : adapter.list()
125
+ ).filter(addressable);
126
+ const others = peers.filter((peer) => peer.sessionId !== own);
127
+
128
+ if (others.length === 0) {
129
+ return {
130
+ message:
131
+ 'No other live session is announced. Start a second session on this host, as this user, ' +
132
+ 'and it appears here.',
133
+ success: true,
134
+ };
135
+ }
136
+
137
+ const lines = peers.map((peer) => describe(peer, own));
138
+ return {
139
+ message: `Live sessions:\n${lines.join('\n')}\n\nSend to one: /peers send <session-id> <message>`,
140
+ success: true,
141
+ };
142
+ }