@xingular/harness 1.6.0-unstable → 2.0.1-unstable

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 (56) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/cli.js +23375 -19883
  3. package/dist/sdk/cli.js +16 -1
  4. package/dist/sdk/harness.js +2 -1
  5. package/dist/sdk/prompt/reporting.d.ts +1 -1
  6. package/dist/sdk/prompt/reporting.js +8 -3
  7. package/dist/sdk/server/methods.js +12 -3
  8. package/dist/sdk/server/serve.d.ts +5 -2
  9. package/dist/sdk/server/serve.js +10 -3
  10. package/dist/sdk/server/socket.d.ts +28 -0
  11. package/dist/sdk/server/socket.js +122 -0
  12. package/dist/sdk/sessions/contracts.d.ts +14 -1
  13. package/dist/sdk/sessions/contracts.js +9 -0
  14. package/dist/sdk/sessions/extensionUi.js +4 -4
  15. package/dist/sdk/sessions/live.d.ts +101 -0
  16. package/dist/sdk/sessions/live.js +265 -0
  17. package/dist/sdk/sessions/registry.d.ts +12 -0
  18. package/dist/sdk/sessions/registry.js +3 -0
  19. package/dist/sdk/sessions/runner.d.ts +25 -3
  20. package/dist/sdk/sessions/runner.js +171 -31
  21. package/dist/sdk/sessions/sessions.d.ts +49 -0
  22. package/dist/sdk/sessions/sessions.js +165 -8
  23. package/dist/sdk/{terminal → sessions}/stallWatch.d.ts +4 -0
  24. package/dist/sdk/{terminal → sessions}/stallWatch.js +10 -4
  25. package/dist/sdk/sessions/subagentService.js +1 -1
  26. package/dist/sdk/terminal/controller.js +1 -3
  27. package/dist/sdk/terminal/daemon.d.ts +44 -0
  28. package/dist/sdk/terminal/daemon.js +264 -0
  29. package/dist/sdk/terminal/liveTerminal.d.ts +96 -0
  30. package/dist/sdk/terminal/liveTerminal.js +436 -0
  31. package/dist/sdk/terminal/standalone.d.ts +19 -1
  32. package/dist/sdk/terminal/standalone.js +75 -25
  33. package/dist/sdk/terminal/terminals.d.ts +43 -0
  34. package/dist/sdk/terminal/terminals.js +171 -14
  35. package/dist/sdk/terminal/tui/commands.d.ts +2 -0
  36. package/dist/sdk/terminal/tui/commands.js +24 -2
  37. package/dist/sdk/terminal/tui/harnessRuntime.d.ts +8 -0
  38. package/dist/sdk/terminal/tui/harnessRuntime.js +771 -0
  39. package/dist/sdk/terminal/tui/thinkingLevels.d.ts +11 -0
  40. package/dist/sdk/terminal/tui/thinkingLevels.js +31 -0
  41. package/dist/terminal-opentui.js +2013 -975
  42. package/dist/terminal-pi.js +9 -0
  43. package/docs/architecture/02-sessions-and-runs.md +61 -3
  44. package/docs/architecture/04-prompt.md +8 -6
  45. package/docs/architecture/10-server-protocol.md +23 -1
  46. package/docs/architecture/11-terminal.md +67 -3
  47. package/docs/architecture/12-storage.md +3 -1
  48. package/docs/architecture/14-surfaces-and-parity.md +4 -0
  49. package/docs/architecture/15-remote-control.md +3 -1
  50. package/docs/architecture/README.md +5 -0
  51. package/docs/decisions/ADR-0001-owned-terminal.md +9 -2
  52. package/docs/decisions/ADR-0083-one-live-session-runtime.md +256 -0
  53. package/docs/parity.md +4 -3
  54. package/docs/releases/2.0.1-unstable.md +63 -0
  55. package/docs/terminal-tui.md +10 -5
  56. package/package.json +1 -1
package/dist/sdk/cli.js CHANGED
@@ -26,7 +26,7 @@ import { runAppsCli, runWikiCli } from "./terminal/cli.js";
26
26
  import { runStandaloneTerminal, standaloneArguments } from "./terminal/standalone.js";
27
27
  import { runWorktreeCli } from "./worktrees/cli.js";
28
28
  const USAGE = `Usage: xagent [--data <dir>] [--state <dir>]
29
- xagent <terminal | serve | wiki | mcp | plugins | hub | refine | web | browser | worktree> [arguments]
29
+ xagent <terminal | serve | daemon | attach | wiki | mcp | plugins | hub | refine | web | browser | worktree> [arguments]
30
30
 
31
31
  --version Print the package version
32
32
  (no command) Start the interactive Harness terminal
@@ -58,6 +58,13 @@ Standalone terminal options:
58
58
  --variant <scope> Run on this variant scope's head (default: interactive profile scope)
59
59
  --remote-control, --rc
60
60
  Turn on /remote-control at start, to continue from a phone or iPad
61
+ --daemon Run in the long-lived harness for these folders (started when needed),
62
+ so a reply keeps running when this window closes
63
+
64
+ Long-lived harness (ADR-0083):
65
+ daemon <start | stop | status>
66
+ Start, stop or check the harness that --daemon windows use
67
+ attach [session] Reattach a window to a reply that kept running after its window closed
61
68
  `;
62
69
  function flags(argv) {
63
70
  const values = {};
@@ -132,6 +139,14 @@ async function main() {
132
139
  return runStandaloneTerminal(terminalArgs);
133
140
  if (command === 'serve')
134
141
  return serveCommand(rest);
142
+ if (command === 'daemon' || command === 'attach') {
143
+ // ADR-0083: the long-lived harness for these folders, and reattaching a window to it.
144
+ const positional = rest[0] && !rest[0].startsWith('-') ? rest[0] : undefined;
145
+ const { resolveStandaloneOptions } = await import("./terminal/standalone.js");
146
+ const { paths, profile } = resolveStandaloneOptions(positional ? rest.slice(1) : rest);
147
+ const daemon = await import("./terminal/daemon.js");
148
+ return command === 'daemon' ? daemon.runDaemonCommand(positional, paths, profile) : daemon.runAttach(paths, profile, positional, process.cwd());
149
+ }
135
150
  if (command === 'wiki')
136
151
  return runWikiCli(rest);
137
152
  if (command === 'mcp')
@@ -208,7 +208,7 @@ export class Harness {
208
208
  this.refine = new Refinements({ root: () => this.env.root, bus: this.bus, trust: this.trust, host: () => this.refineHost, runtime: refineRuntime });
209
209
  this.lessons = new Lessons({ root: () => this.env.root, runtime: refineRuntime });
210
210
  this.pageNotes = new NoteDeliveries({ root: () => this.env.root, bus: this.bus, terminals: () => this.terminals ?? null });
211
- this.terminals = new Terminals({ trust: this.trust, subagents: this.subagents, sideQuestions: this.sideQuestions, resolveCapabilities: (caller, tools, cwd) => capabilities.resolve(this, { agentId: caller.agentId ?? null, projectId: caller.projectId ?? null, cwd, tools }), env: this.env, stateDir: this.stateDir, accounts: this.accounts, runs: this.runs, approvals: this.approvals, apps: this.apps, policy: this.policy, bus: this.bus, plugins: this.plugins, usage: this.usage, usageLimits: this.usageLimits, pluginControl: { list: (scope) => pluginApi.list(this, scope), attach: (params, on) => pluginApi.attach(this, params, on), trust: (scope) => pluginApi.trust(this, scope), untrust: (scope) => pluginApi.untrust(this, scope), configure: (params) => pluginApi.configure(this, params), install: params => pluginInstall.install(this, params), remove: params => pluginInstall.remove(this, params), proposals: { list: () => pluginProposals.list(this.env.root), preview: id => pluginProposals.preview(this.env.root, id), accept: (id, hash) => pluginProposals.accept(this, id, hash), reject: (id, hash) => pluginProposals.reject(this.env.root, id, hash) } }, assets: options.assets, hub: this.hub, world: this.world, doctor: this.doctor, worktrees: this.worktrees, noteCapture: this.noteCapture, pageNotes: this.pageNotes, refine: this.refine });
211
+ this.terminals = new Terminals({ trust: this.trust, subagents: this.subagents, sideQuestions: this.sideQuestions, resolveCapabilities: (caller, tools, cwd) => capabilities.resolve(this, { agentId: caller.agentId ?? null, projectId: caller.projectId ?? null, cwd, tools }), env: this.env, stateDir: this.stateDir, accounts: this.accounts, runs: this.runs, approvals: this.approvals, apps: this.apps, policy: this.policy, bus: this.bus, plugins: this.plugins, usage: this.usage, usageLimits: this.usageLimits, pluginControl: { list: (scope) => pluginApi.list(this, scope), attach: (params, on) => pluginApi.attach(this, params, on), trust: (scope) => pluginApi.trust(this, scope), untrust: (scope) => pluginApi.untrust(this, scope), configure: (params) => pluginApi.configure(this, params), install: params => pluginInstall.install(this, params), remove: params => pluginInstall.remove(this, params), proposals: { list: () => pluginProposals.list(this.env.root), preview: id => pluginProposals.preview(this.env.root, id), accept: (id, hash) => pluginProposals.accept(this, id, hash), reject: (id, hash) => pluginProposals.reject(this.env.root, id, hash) } }, assets: options.assets, hub: this.hub, world: this.world, doctor: this.doctor, worktrees: this.worktrees, noteCapture: this.noteCapture, pageNotes: this.pageNotes, refine: this.refine, sessionService: this.sessions });
212
212
  this.ready = this.apps.start().catch((error) => log('connected apps', error instanceof Error ? error.message : String(error)));
213
213
  this.runs.listen((event, run) => {
214
214
  this.accounts.onRunEvent(event, run);
@@ -359,6 +359,7 @@ export class Harness {
359
359
  await this.refine.close();
360
360
  await this.lessons.close();
361
361
  await this.terminals.close();
362
+ this.sessions.close();
362
363
  await this.noteCapture.close();
363
364
  await this.sideQuestions.close();
364
365
  await this.runs.interruptAll();
@@ -1,2 +1,2 @@
1
- export declare const REPORTING_GUIDANCE = "The final answer must always be fully self-contained: users should never need to read earlier commentary updates, since they are collapsed after the final answer is shown to users. In your final answer, focus on the most important information.\n\nAssume the person may have stepped away and lost the thread. Write so they can pick the work back up cold: give enough context to understand the outcome and what remains, and match the explanation to what their request signals about their experience.\n\nReporting is for work that is finished or genuinely blocked. Do not end a turn to report work you can still do.\n\n### Technical communication\n\nUse plain language over jargon, and reference technical details only to the degree that it actually helps the conversation. Communicate complex concepts in a clear and cohesive manner. The user should never have to read your writing twice to understand it.\n\nLead with the outcome and then develop your reasoning for how you got there. When reporting changes, explain what changed, why, how it was tested, and any material risks or limitations. Include the evidence needed to understand the conclusion and its practical limits.\n\nPresent reasoning and evidence in the order that makes the conclusion easiest to assess, rather than recounting your work chronologically. Summarize routine verification instead of listing every check.\n\nEvery claim needs evidence. If you did not run a check, say so; never imply that it passed. If a check failed, report the failure and relevant output. When a check passed, state that plainly.\n\nWhen someone needs to act, state the specific next step and why. Omit it when there is no person-owned action; do not add a filler ending.\n\nUse tables for mappings or comparisons.\n\n### Headings\n\nFor a substantive reply with distinct parts, often structure it with Markdown headings: use a # title when it helps, then ## headings for meaningful sections. Name each heading for the content of this reply. \"What changed\", \"Checks\" and \"Things to know\" are examples, not a fixed set; do not reuse them by default. Short replies may stay in prose; do not force headings or create empty sections.\n\n### Shape by work\n\n| The work | What the reader needs |\n|---|---|\n| A code change | outcome, what changed and why, how it was tested, risks |\n| A document or copy | what changed in the text, where to read it |\n| An answer or research | the answer, then the evidence and its limits |\n| A diagnosis | the cause, the fix, what to watch for |\n| A publish or release | what is ready, what to review, the risks |\n| Config or operations | what changed where, the rollback, who runs it |\n\n### Length\n\nScale detail to complexity: a simple change usually needs one or two sentences plus the validation, and a larger change needs the reasoning, the evidence and the risks. Research is as long as the evidence needs. Never repeat the request, the plan or the previous message to fill space.\n\n### Prose and lists\n\nWrite in connected prose. Use lists only when the information is genuinely parallel, sequential, or easier to compare, and avoid nested lists unless the hierarchy cannot be expressed clearly in prose. Use a table when several items share fields.\n\n### References and formatting\n\nFollow the file-link and citation format supplied by the active session or host. Cite files or sources that help the person assess the result or continue the work; do not impose an extra format.\n\n### Subagent reports\n\nWhen you are a subagent, give the caller a concise report of what you did and the key findings; the caller will integrate and relay the result. Include absolute file paths and evidence needed to use the findings. Include snippets only when the exact text is load-bearing, and do not recap code you merely read.\n\n### Examples\n\nThese examples show possible shapes, not a fixed heading set or wording.\n\n#### Example 1 \u2014 a screen change\n\n # The selected tab survives a reload\n\n ## Stored choice\n Settings now remembers the last selected tab.\n\n ## Verification\n `npm test`: 12 passed, including the two tab tests. I did not open it in a browser, so the\n visual result is unverified.\n\n ## Older settings\n A missing value falls back to \"General\"; an old key is ignored, not migrated.\n\n#### Example 2 \u2014 a blocked publish\n\n The release notes for 1.4.0 are written. The tag and the publish did not run: this shell has no\n authenticated GitHub CLI.\n\n Run `gh auth login --hostname github.com` in the project folder; then I can tag `v1.4.0` and\n push. Nothing has been published.\n\n### Before you send\n\n- The first line says what happened and whether it is done.\n- Every claim carries the evidence that supports it; any check not run is named as unrun.\n- Any headings name this reply's actual content and are not forced.\n- Nothing is repeated from the request or the plan.\n";
1
+ export declare const REPORTING_GUIDANCE = "The final answer must always be fully self-contained: users should never need to read earlier commentary updates, since they are collapsed after the final answer is shown to users. In your final answer, focus on the most important information.\n\nAssume the person may have stepped away and lost the thread. Write so they can pick the work back up cold: give enough context to understand the outcome and what remains, and match the explanation to what their request signals about their experience.\n\nReporting is for work that is finished or genuinely blocked. Do not end a turn to report work you can still do.\n\n### Technical communication\n\nUse plain language over jargon, and reference technical details only to the degree that it actually helps the conversation. Communicate complex concepts in a clear and cohesive manner. The user should never have to read your writing twice to understand it.\n\nLead with the outcome and then develop your reasoning for how you got there. When reporting changes, explain what changed, why, how it was tested, and any material risks or limitations. Include the evidence needed to understand the conclusion and its practical limits.\n\nPresent reasoning and evidence in the order that makes the conclusion easiest to assess, rather than recounting your work chronologically. Summarize routine verification instead of listing every check.\n\nEvery claim needs evidence. If you did not run a check, say so; never imply that it passed. If a check failed, report the failure and relevant output. When a check passed, state that plainly.\n\nWhen someone needs to act, state the specific next step and why. Omit it when there is no person-owned action; do not add a filler ending.\n\nUse tables for mappings or comparisons.\n\n### Headings\n\nFor a substantive reply with distinct parts, often structure it with Markdown headings: use a # title when it helps, then ## headings for meaningful sections. Name each heading for the content of this reply. \"What changed\", \"Checks\" and \"Things to know\" are examples, not a fixed set; do not reuse them by default. Short replies may stay in prose; do not force headings or create empty sections.\n\n### Shape by work\n\n| The work | What the reader needs |\n|---|---|\n| A code change | outcome, what changed and why, how it was tested, risks |\n| A document or copy | what changed in the text, where to read it |\n| An answer or research | the answer, then the evidence and its limits |\n| A diagnosis | the cause, the fix, what to watch for |\n| A publish or release | what is ready, what to review, the risks |\n| Config or operations | what changed where, the rollback, who runs it |\n\n### Length\n\nScale detail to complexity: a simple change usually needs one or two sentences plus the validation, and a larger change needs the reasoning, the evidence and the risks. Research is as long as the evidence needs. Never repeat the request, the plan or the previous message to fill space.\n\n### Prose and lists\n\nWrite in connected prose. Use lists only when the information is genuinely parallel, sequential, or easier to compare, and avoid nested lists unless the hierarchy cannot be expressed clearly in prose. Use a table when several items share fields.\n\n### References and formatting\n\nFollow the file-link and citation format supplied by the active session or host. Cite files or sources that help the person assess the result or continue the work; do not impose an extra format. For code-like output, use fenced Markdown blocks with a language identifier such as `python`, `bash` or `typescript`; put commands the person must run in a `bash` block, and keep short references inline.\n\n### Subagent reports\n\nWhen you are a subagent, give the caller a concise report of what you did and the key findings; the caller will integrate and relay the result. Include absolute file paths and evidence needed to use the findings. Include snippets only when the exact text is load-bearing, and do not recap code you merely read.\n\n### Examples\n\nThese examples show possible shapes, not a fixed heading set or wording.\n\n#### Example 1 \u2014 a screen change\n\n # The selected tab survives a reload\n\n ## Stored choice\n Settings now remembers the last selected tab.\n\n ## Verification\n `npm test`: 12 passed, including the two tab tests. I did not open it in a browser, so the\n visual result is unverified.\n\n ## Older settings\n A missing value falls back to \"General\"; an old key is ignored, not migrated.\n\n#### Example 2 \u2014 a blocked publish\n\n The release notes for 1.4.0 are written. The tag and the publish did not run: this shell has no\n authenticated GitHub CLI.\n\n Run this in the project folder:\n\n ```bash\n gh auth login --hostname github.com\n ```\n\n Then I can tag `v1.4.0` and push. Nothing has been published.\n\n### Before you send\n\n- The first line says what happened and whether it is done.\n- Every claim carries the evidence that supports it; any check not run is named as unrun.\n- Any headings name this reply's actual content and are not forced.\n- Nothing is repeated from the request or the plan.\n";
2
2
  export declare const REPORTING_BUDGET = 12000;
@@ -47,7 +47,7 @@ Write in connected prose. Use lists only when the information is genuinely paral
47
47
 
48
48
  ### References and formatting
49
49
 
50
- Follow the file-link and citation format supplied by the active session or host. Cite files or sources that help the person assess the result or continue the work; do not impose an extra format.
50
+ Follow the file-link and citation format supplied by the active session or host. Cite files or sources that help the person assess the result or continue the work; do not impose an extra format. For code-like output, use fenced Markdown blocks with a language identifier such as \`python\`, \`bash\` or \`typescript\`; put commands the person must run in a \`bash\` block, and keep short references inline.
51
51
 
52
52
  ### Subagent reports
53
53
 
@@ -76,8 +76,13 @@ These examples show possible shapes, not a fixed heading set or wording.
76
76
  The release notes for 1.4.0 are written. The tag and the publish did not run: this shell has no
77
77
  authenticated GitHub CLI.
78
78
 
79
- Run \`gh auth login --hostname github.com\` in the project folder; then I can tag \`v1.4.0\` and
80
- push. Nothing has been published.
79
+ Run this in the project folder:
80
+
81
+ \`\`\`bash
82
+ gh auth login --hostname github.com
83
+ \`\`\`
84
+
85
+ Then I can tag \`v1.4.0\` and push. Nothing has been published.
81
86
 
82
87
  ### Before you send
83
88
 
@@ -326,13 +326,18 @@ export function methods(harness) {
326
326
  // ---- Sessions ----------------------------------------------------------------------------------
327
327
  'sessions.spawnSubagent': (params) => sessions.spawnSubagent(params),
328
328
  'sessions.run': (params) => sessions.run(params),
329
+ // Live sessions (ADR-0083): Pi sessions kept open between the runs of a conversation.
330
+ 'sessions.live': () => ({ sessions: sessions.liveSessions() }),
331
+ 'sessions.close': (params) => sessions.closeLive(text(params, 'sessionId')),
332
+ 'sessions.followUp': (params) => sessions.followUp(text(params, 'sessionId'), text(params, 'text')),
333
+ 'sessions.followUp.clear': (params) => ({ removed: sessions.live.clearQueue(text(params, 'sessionId')) }),
329
334
  'sessions.history': (params) => ({ sessionFile: selectHistory({ sessionDir: text(params, 'sessionDir'), resume: params.resume ?? null }) }),
330
335
  // The commands that need no model act on the saved history; see sessions/control.ts.
331
336
  'sessions.tree': (params) => control.tree(sessionRef(params)),
332
- 'sessions.navigate': (params) => control.navigate({ ...sessionRef(params), entryId: text(params, 'entryId') }),
337
+ 'sessions.navigate': (params) => { sessions.live.yieldHistory({ sessionDir: sessionRef(params).sessionDir }); return control.navigate({ ...sessionRef(params), entryId: text(params, 'entryId') }); },
333
338
  'sessions.fork': (params) => control.fork({ ...sessionRef(params), entryId: text(params, 'entryId'), targetDir: text(params, 'targetDir') }),
334
339
  'sessions.clone': (params) => control.clone({ ...sessionRef(params), targetDir: text(params, 'targetDir') }),
335
- 'sessions.rename': (params) => control.rename({ ...sessionRef(params), name: text(params, 'name') }),
340
+ 'sessions.rename': (params) => { sessions.live.yieldHistory({ sessionDir: sessionRef(params).sessionDir }); return control.rename({ ...sessionRef(params), name: text(params, 'name') }); },
336
341
  'sessions.lastReply': (params) => control.lastReply(sessionRef(params)),
337
342
  'sessions.export': (params) => control.exportJsonl({ ...sessionRef(params), path: text(params, 'path') }),
338
343
  // Each structured turn loads its resources fresh; this reloads what lives between turns.
@@ -347,7 +352,11 @@ export function methods(harness) {
347
352
  'terminal.send': (params) => terminals.send({ sessionId: text(params, 'sessionId'), runId: optional(params, 'runId'), text: text(params, 'text') }),
348
353
  'terminal.input': (params) => terminals.input(text(params, 'sessionId'), typeof params.data === 'string' ? params.data : ''),
349
354
  'terminal.state': (params) => terminals.state(text(params, 'sessionId')),
350
- 'terminal.dispose': (params) => terminals.dispose(text(params, 'sessionId'))
355
+ 'terminal.dispose': (params) => terminals.dispose(text(params, 'sessionId')),
356
+ // ADR-0083 phase 3: a client terminal's reply may outlive its window.
357
+ 'terminal.detach': (params) => terminals.detach(text(params, 'sessionId')),
358
+ 'terminal.detached': () => ({ sessions: terminals.detached() }),
359
+ 'terminal.takeRelaunch': (params) => terminals.takeRelaunch(text(params, 'sessionId'))
351
360
  };
352
361
  const overlap = Object.keys(foundation).find(name => name in builtins);
353
362
  if (overlap)
@@ -5,6 +5,9 @@ export interface Served {
5
5
  /** Stops live work and releases what the harness holds. */
6
6
  shutdown(): Promise<void>;
7
7
  }
8
- /** Registers the method table on `peer` and forwards events and notices to it. */
9
- export declare function attach(harness: Harness, peer: RpcPeer): void;
8
+ /**
9
+ * Registers the method table on `peer` and forwards events and notices to it. Returns what stops the
10
+ * forwarding, for a peer that goes away before the harness does (a socket connection).
11
+ */
12
+ export declare function attach(harness: Harness, peer: RpcPeer): () => void;
10
13
  export declare function serve(harness: Harness, transport: Transport): Served;
@@ -1,12 +1,19 @@
1
1
  import { methods } from "./methods.js";
2
2
  import { RpcPeer } from "./rpc.js";
3
- /** Registers the method table on `peer` and forwards events and notices to it. */
3
+ /**
4
+ * Registers the method table on `peer` and forwards events and notices to it. Returns what stops the
5
+ * forwarding, for a peer that goes away before the harness does (a socket connection).
6
+ */
4
7
  export function attach(harness, peer) {
5
8
  for (const [name, handler] of Object.entries(methods(harness)))
6
9
  peer.handle(name, handler);
7
10
  peer.handle('harness.methods', () => peer.methods());
8
- harness.runs.listen((event) => peer.send({ event }));
9
- harness.bus.on((notice) => peer.send({ notice }));
11
+ const stopEvents = harness.runs.listen((event) => peer.send({ event }));
12
+ const stopNotices = harness.bus.on((notice) => peer.send({ notice }));
13
+ return () => {
14
+ stopEvents();
15
+ stopNotices();
16
+ };
10
17
  }
11
18
  export function serve(harness, transport) {
12
19
  const peer = new RpcPeer(transport);
@@ -0,0 +1,28 @@
1
+ import { type Socket } from 'node:net';
2
+ import type { Harness } from "../harness.js";
3
+ import { type Transport } from "./rpc.js";
4
+ /** Protocol lines over one socket. */
5
+ export declare function socketTransport(socket: Socket): Transport;
6
+ export interface SocketServer {
7
+ path: string;
8
+ close(): Promise<void>;
9
+ /** Connections open now. */
10
+ connections(): number;
11
+ }
12
+ /**
13
+ * Serves `harness` on a socket at `path`, readable and writable by this user only. Each connection
14
+ * gets the whole method table and the run events and notices while it is open.
15
+ */
16
+ export declare function serveSocket(harness: Harness, path: string, extra?: Record<string, (params: Record<string, unknown>) => unknown>): Promise<SocketServer>;
17
+ export interface HarnessClient {
18
+ call<T = unknown>(method: string, params?: Record<string, unknown>, timeoutMs?: number): Promise<T>;
19
+ /** Run events and notices the harness sends while connected. */
20
+ onMessage(listener: (message: {
21
+ event?: unknown;
22
+ notice?: unknown;
23
+ }) => void): () => void;
24
+ close(): void;
25
+ readonly closed: Promise<void>;
26
+ }
27
+ /** Connects to a harness serving on `path`. */
28
+ export declare function connectHarness(path: string): Promise<HarnessClient>;
@@ -0,0 +1,122 @@
1
+ // The server protocol (page 10) on a local Unix socket, one peer per connection, so more than one
2
+ // client can reach the same long-lived harness (ADR-0083, phase 3). The framing is the stdio
3
+ // protocol's: one JSON message per line.
4
+ import { chmodSync, existsSync, rmSync } from 'node:fs';
5
+ import { createConnection, createServer } from 'node:net';
6
+ import { createInterface } from 'node:readline';
7
+ import { HarnessError } from "../core/errors.js";
8
+ import { PROTOCOL_VERSION, RpcPeer } from "./rpc.js";
9
+ import { attach } from "./serve.js";
10
+ /** Protocol lines over one socket. */
11
+ export function socketTransport(socket) {
12
+ const reader = createInterface({ input: socket, crlfDelay: Infinity });
13
+ return {
14
+ write: (line) => { if (!socket.destroyed)
15
+ socket.write(`${line}\n`); },
16
+ onLine: (listener) => void reader.on('line', listener)
17
+ };
18
+ }
19
+ /**
20
+ * Serves `harness` on a socket at `path`, readable and writable by this user only. Each connection
21
+ * gets the whole method table and the run events and notices while it is open.
22
+ */
23
+ export async function serveSocket(harness, path, extra = {}) {
24
+ if (existsSync(path))
25
+ rmSync(path, { force: true });
26
+ const open = new Set();
27
+ const server = createServer((socket) => {
28
+ open.add(socket);
29
+ const peer = new RpcPeer(socketTransport(socket));
30
+ const stop = attach(harness, peer);
31
+ for (const [name, handler] of Object.entries(extra))
32
+ peer.handle(name, handler);
33
+ const gone = () => {
34
+ open.delete(socket);
35
+ stop();
36
+ };
37
+ socket.once('close', gone);
38
+ socket.on('error', () => socket.destroy());
39
+ });
40
+ await new Promise((resolve, reject) => {
41
+ server.once('error', reject);
42
+ server.listen(path, () => {
43
+ server.off('error', reject);
44
+ resolve();
45
+ });
46
+ });
47
+ chmodSync(path, 0o600);
48
+ return {
49
+ path,
50
+ connections: () => open.size,
51
+ close: async () => {
52
+ for (const socket of open)
53
+ socket.destroy();
54
+ await new Promise((resolve) => server.close(() => resolve()));
55
+ rmSync(path, { force: true });
56
+ }
57
+ };
58
+ }
59
+ /** Connects to a harness serving on `path`. */
60
+ export async function connectHarness(path) {
61
+ const socket = createConnection(path);
62
+ await new Promise((resolve, reject) => {
63
+ socket.once('connect', resolve);
64
+ socket.once('error', reject);
65
+ });
66
+ const reader = createInterface({ input: socket, crlfDelay: Infinity });
67
+ const pending = new Map();
68
+ const listeners = new Set();
69
+ let next = 0;
70
+ const closed = new Promise((resolve) => socket.once('close', () => {
71
+ for (const [, call] of pending) {
72
+ clearTimeout(call.timer);
73
+ call.reject(new HarnessError('harness_unavailable', 'The harness closed the connection.'));
74
+ }
75
+ pending.clear();
76
+ resolve();
77
+ }));
78
+ socket.on('error', () => undefined);
79
+ reader.on('line', (line) => {
80
+ let message;
81
+ try {
82
+ message = JSON.parse(line);
83
+ }
84
+ catch {
85
+ return;
86
+ }
87
+ if (message.v !== PROTOCOL_VERSION)
88
+ return;
89
+ if (typeof message.id === 'string' && pending.has(message.id)) {
90
+ const call = pending.get(message.id);
91
+ pending.delete(message.id);
92
+ clearTimeout(call.timer);
93
+ if (message.error) {
94
+ const shape = message.error;
95
+ call.reject(new HarnessError(shape.code, shape.message, shape.retryHint ?? null));
96
+ }
97
+ else
98
+ call.resolve(message.result);
99
+ return;
100
+ }
101
+ if ('event' in message || 'notice' in message)
102
+ for (const listener of listeners)
103
+ listener(message);
104
+ });
105
+ return {
106
+ call: (method, params = {}, timeoutMs = 60_000) => new Promise((resolve, reject) => {
107
+ const id = `c${++next}`;
108
+ const timer = setTimeout(() => {
109
+ pending.delete(id);
110
+ reject(new HarnessError('harness_timeout', `The harness did not answer ${method}.`));
111
+ }, timeoutMs);
112
+ pending.set(id, { resolve: resolve, reject, timer });
113
+ socket.write(`${JSON.stringify({ v: PROTOCOL_VERSION, id, method, params })}\n`);
114
+ }),
115
+ onMessage: (listener) => {
116
+ listeners.add(listener);
117
+ return () => { listeners.delete(listener); };
118
+ },
119
+ close: () => socket.end(),
120
+ closed
121
+ };
122
+ }
@@ -479,6 +479,19 @@ export declare const runEventPayloadSchemas: {
479
479
  multiSelect: z.ZodBoolean;
480
480
  allowText: z.ZodBoolean;
481
481
  userOnly: z.ZodOptional<z.ZodBoolean>;
482
+ dialog: z.ZodOptional<z.ZodObject<{
483
+ kind: z.ZodEnum<{
484
+ input: "input";
485
+ select: "select";
486
+ confirm: "confirm";
487
+ editor: "editor";
488
+ }>;
489
+ title: z.ZodString;
490
+ message: z.ZodOptional<z.ZodString>;
491
+ placeholder: z.ZodOptional<z.ZodString>;
492
+ prefill: z.ZodOptional<z.ZodString>;
493
+ options: z.ZodOptional<z.ZodArray<z.ZodString>>;
494
+ }, z.core.$strip>>;
482
495
  }, z.core.$strip>;
483
496
  'question.answered': z.ZodObject<{
484
497
  batchId: z.ZodOptional<z.ZodString>;
@@ -492,10 +505,10 @@ export declare const runEventPayloadSchemas: {
492
505
  'extension.notice': z.ZodObject<{
493
506
  kind: z.ZodEnum<{
494
507
  error: "error";
508
+ title: "title";
495
509
  notify: "notify";
496
510
  status: "status";
497
511
  widget: "widget";
498
- title: "title";
499
512
  }>;
500
513
  level: z.ZodOptional<z.ZodEnum<{
501
514
  error: "error";
@@ -223,6 +223,15 @@ const questionRequestedPayload = z.object({
223
223
  multiSelect: z.boolean(),
224
224
  allowText: z.boolean(),
225
225
  userOnly: z.boolean().optional(),
226
+ /** The Pi extension dialog this question stands for (ADR-0083); a host may ignore it. */
227
+ dialog: z.object({
228
+ kind: z.enum(['select', 'confirm', 'input', 'editor']),
229
+ title: z.string().max(4_000),
230
+ message: z.string().max(4_000).optional(),
231
+ placeholder: z.string().max(4_000).optional(),
232
+ prefill: z.string().max(100_000).optional(),
233
+ options: z.array(z.string().max(4_000)).max(200).optional()
234
+ }).optional(),
226
235
  ...questionBatchFields
227
236
  }).superRefine(questionBatchRefinement).superRefine((value, context) => {
228
237
  const error = questionSketchError(value.uiSketch, value.options);
@@ -24,11 +24,11 @@ const plainTheme = new Proxy({}, { get: () => (...args) => String(args[args.leng
24
24
  export function extensionUi(host) {
25
25
  const context = {
26
26
  select: (title, options, opts) => dismissable(options.length
27
- ? host.ask({ question: clip(title), options: options.map((label, index) => ({ label, ...(index === 0 ? { recommended: true } : {}) })), multiSelect: false, allowText: false }).then((answer) => answer.selected[0])
27
+ ? host.ask({ question: clip(title), options: options.map((label, index) => ({ label, ...(index === 0 ? { recommended: true } : {}) })), multiSelect: false, allowText: false, dialog: { kind: 'select', title: clip(title), options: options.map(clip) } }).then((answer) => answer.selected[0])
28
28
  : Promise.resolve(undefined), undefined, opts),
29
- confirm: (title, message, opts) => dismissable(host.ask({ question: clip(message ? `${title}\n\n${message}` : title), options: [{ label: 'Yes' }, { label: 'No', recommended: true }], multiSelect: false, allowText: false }).then((answer) => answer.selected[0] === 'Yes'), false, opts),
30
- input: (title, placeholder, opts) => dismissable(host.ask({ question: clip(placeholder ? `${title} (${placeholder})` : title), options: [{ label: 'Type your response', recommended: true }], multiSelect: false, allowText: true }).then((answer) => answer.text || undefined), undefined, opts),
31
- editor: (title, prefill) => host.ask({ question: clip(prefill ? `${title}\n\n${prefill}` : title), options: [{ label: 'Type your response', recommended: true }], multiSelect: false, allowText: true }).then((answer) => answer.text || undefined),
29
+ confirm: (title, message, opts) => dismissable(host.ask({ question: clip(message ? `${title}\n\n${message}` : title), options: [{ label: 'Yes' }, { label: 'No', recommended: true }], multiSelect: false, allowText: false, dialog: { kind: 'confirm', title: clip(title), ...(message ? { message: clip(message) } : {}) } }).then((answer) => answer.selected[0] === 'Yes'), false, opts),
30
+ input: (title, placeholder, opts) => dismissable(host.ask({ question: clip(placeholder ? `${title} (${placeholder})` : title), options: [{ label: 'Type your response', recommended: true }], multiSelect: false, allowText: true, dialog: { kind: 'input', title: clip(title), ...(placeholder ? { placeholder: clip(placeholder) } : {}) } }).then((answer) => answer.text || undefined), undefined, opts),
31
+ editor: (title, prefill) => host.ask({ question: clip(prefill ? `${title}\n\n${prefill}` : title), options: [{ label: 'Type your response', recommended: true }], multiSelect: false, allowText: true, dialog: { kind: 'editor', title: clip(title), ...(prefill ? { prefill: prefill.slice(0, 100_000) } : {}) } }).then((answer) => answer.text || undefined),
32
32
  notify: (message, level) => host.notice({ kind: 'notify', level: level ?? 'info', text: clip(message) }),
33
33
  setStatus: (key, text) => host.notice({ kind: 'status', key, text: clip(text ?? '') }),
34
34
  setWidget: (key, content) => {
@@ -0,0 +1,101 @@
1
+ import type { PiSessionLike } from "./runner.js";
2
+ import type { RunRecord } from "./contracts.js";
3
+ /** How long an idle live session stays open, unless the caller sets another time. */
4
+ export declare const LIVE_IDLE_MS: number;
5
+ /** Follow-up messages one live session may hold. */
6
+ export declare const MAX_FOLLOW_UPS = 20;
7
+ export interface LiveSessionStatus {
8
+ sessionId: string;
9
+ nativeSessionId: string;
10
+ sessionFile: string | null;
11
+ /** The run using the session now, or null while it is idle. */
12
+ activeRunId: string | null;
13
+ lastRunId: string;
14
+ /** Runs this live session has served, the first included. */
15
+ runs: number;
16
+ openedAt: string;
17
+ idleSince: string | null;
18
+ /** Follow-up messages waiting for the active run to settle, oldest first. */
19
+ followUps: string[];
20
+ }
21
+ export interface LiveSessionsOptions {
22
+ idleMs?: number;
23
+ /** Reads a settled run, to decide whether its queued follow-ups go now. */
24
+ record(runId: string): RunRecord | null;
25
+ /** Starts a follow-up as the next run of the session. */
26
+ followUp(sessionId: string, lastRunId: string, text: string): Promise<void>;
27
+ log?(message: string): void;
28
+ }
29
+ /** The run fields a live session must share with a run before it may serve it. */
30
+ export declare function livePin(fields: Record<string, unknown>): string;
31
+ export declare class LiveSessions {
32
+ private readonly options;
33
+ private readonly entries;
34
+ /** Watchers of each session's native Pi events; they outlive a session replaced at a pin change. */
35
+ private readonly watchers;
36
+ private readonly unsubscribers;
37
+ /** Follow-ups of a session closed at a pin change, handed to the session that replaces it. */
38
+ private readonly parked;
39
+ private readonly idleMs;
40
+ constructor(options: LiveSessionsOptions);
41
+ /** Whether a run of this session is using its live session now. */
42
+ busy(sessionId: string): boolean;
43
+ /**
44
+ * The live session for this run, now marked in use, or null when there is none to reuse. An idle
45
+ * session whose pins, history or native id differ is closed here, at the turn boundary.
46
+ */
47
+ claim(sessionId: string, pin: string, runId: string, historyFile: string | null, nativeSessionId?: string | null): PiSessionLike | null;
48
+ /** Records the session a run just opened, so the next run of the conversation can reuse it. */
49
+ hold(sessionId: string, pin: string, pi: PiSessionLike, runId: string, sessionDir: string): void;
50
+ /** The run has started on the session: count it. */
51
+ started(sessionId: string, runId: string): void;
52
+ /**
53
+ * The run settled. The session stays open for the next run. A queued follow-up is sent after a
54
+ * reply that completed or that the person stopped; after a failure, or an interruption the person
55
+ * did not ask for, it stays parked (page 02, queued-message dispatch). `close` ends the session
56
+ * instead of keeping it, with its queue parked.
57
+ */
58
+ release(sessionId: string, runId: string, close?: boolean): void;
59
+ /**
60
+ * Something other than the live session is about to change this history: a separate command
61
+ * run or a saved-history edit. An idle live session closes, so its next run reads the file again;
62
+ * one that is replying refuses the change.
63
+ */
64
+ yieldHistory(match: {
65
+ sessionId?: string;
66
+ sessionDir?: string;
67
+ }): void;
68
+ /** The session could not serve the run after all: forget it and close it. */
69
+ discard(sessionId: string, pi: PiSessionLike): void;
70
+ /**
71
+ * Queues a message for after the active run, or reports that the session is idle so the caller
72
+ * starts it now.
73
+ */
74
+ queue(sessionId: string, text: string): {
75
+ queued: number;
76
+ } | {
77
+ idle: true;
78
+ lastRunId: string;
79
+ };
80
+ /** Drops the queued follow-ups and returns them, oldest first. */
81
+ clearQueue(sessionId: string): string[];
82
+ /**
83
+ * Receives the native Pi events of this conversation's live session, and of the one that replaces
84
+ * it, until the returned function is called (ADR-0083: a client renders from them).
85
+ */
86
+ watch(sessionId: string, watcher: (event: unknown) => void): () => void;
87
+ /** The open Pi session of a conversation, for a read of its current state. */
88
+ session(sessionId: string): PiSessionLike | null;
89
+ status(sessionId: string): LiveSessionStatus | null;
90
+ list(): LiveSessionStatus[];
91
+ /** Closes a live session now, or when its active run settles. */
92
+ close(sessionId: string): {
93
+ closed: boolean;
94
+ afterRun: string | null;
95
+ };
96
+ closeAll(): void;
97
+ private statusOf;
98
+ private startTimer;
99
+ private stopTimer;
100
+ private dispose;
101
+ }