@xingular/harness 1.3.1-unstable.2 → 1.3.1-unstable.4

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 (125) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +1 -1
  3. package/dist/cli.js +3680 -1947
  4. package/dist/sdk/cli.js +2 -0
  5. package/dist/sdk/commands/registry.d.ts +15 -0
  6. package/dist/sdk/commands/registry.js +2 -0
  7. package/dist/sdk/computer/screenshot.d.ts +32 -0
  8. package/dist/sdk/computer/screenshot.js +35 -0
  9. package/dist/sdk/context/attribution.d.ts +18 -0
  10. package/dist/sdk/context/attribution.js +295 -0
  11. package/dist/sdk/context/types.d.ts +94 -0
  12. package/dist/sdk/context/types.js +10 -0
  13. package/dist/sdk/index.d.ts +2 -1
  14. package/dist/sdk/index.js +2 -1
  15. package/dist/sdk/plugins/builtin.js +1 -0
  16. package/dist/sdk/prompt/compile.d.ts +42 -2
  17. package/dist/sdk/prompt/compile.js +87 -15
  18. package/dist/sdk/remote/addresses.d.ts +19 -0
  19. package/dist/sdk/remote/addresses.js +52 -0
  20. package/dist/sdk/remote/pairing.d.ts +59 -0
  21. package/dist/sdk/remote/pairing.js +160 -0
  22. package/dist/sdk/remote/seal.d.ts +28 -0
  23. package/dist/sdk/remote/seal.js +84 -0
  24. package/dist/sdk/remote/server.d.ts +28 -0
  25. package/dist/sdk/remote/server.js +158 -0
  26. package/dist/sdk/remote/service.d.ts +125 -0
  27. package/dist/sdk/remote/service.js +330 -0
  28. package/dist/sdk/remote/webClient.d.ts +4 -0
  29. package/dist/sdk/remote/webClient.js +444 -0
  30. package/dist/sdk/server/methods.js +3 -0
  31. package/dist/sdk/sessions/contracts.d.ts +4 -0
  32. package/dist/sdk/sessions/contracts.js +2 -0
  33. package/dist/sdk/sessions/planMode.d.ts +26 -1
  34. package/dist/sdk/sessions/planMode.js +48 -1
  35. package/dist/sdk/sessions/runner.d.ts +12 -0
  36. package/dist/sdk/sessions/runner.js +34 -2
  37. package/dist/sdk/sessions/sessions.js +1 -0
  38. package/dist/sdk/sessions/subagentService.d.ts +11 -1
  39. package/dist/sdk/sessions/subagentService.js +120 -38
  40. package/dist/sdk/sessions/subagents.d.ts +31 -0
  41. package/dist/sdk/sessions/subagents.js +45 -0
  42. package/dist/sdk/sessions/workSlice.d.ts +5 -9
  43. package/dist/sdk/sessions/workSlice.js +8 -149
  44. package/dist/sdk/terminal/client.d.ts +3 -1
  45. package/dist/sdk/terminal/client.js +15 -6
  46. package/dist/sdk/terminal/control.d.ts +1 -1
  47. package/dist/sdk/terminal/control.js +2 -2
  48. package/dist/sdk/terminal/controller.d.ts +22 -0
  49. package/dist/sdk/terminal/controller.js +104 -13
  50. package/dist/sdk/terminal/piExtension.js +106 -4
  51. package/dist/sdk/terminal/stallWatch.d.ts +13 -0
  52. package/dist/sdk/terminal/stallWatch.js +16 -0
  53. package/dist/sdk/terminal/standalone.d.ts +2 -0
  54. package/dist/sdk/terminal/standalone.js +9 -2
  55. package/dist/sdk/terminal/terminals.d.ts +14 -0
  56. package/dist/sdk/terminal/terminals.js +98 -2
  57. package/dist/sdk/terminal/tui/commandPages.d.ts +4 -1
  58. package/dist/sdk/terminal/tui/commandPages.js +12 -0
  59. package/dist/sdk/terminal/tui/commands.d.ts +3 -2
  60. package/dist/sdk/terminal/tui/commands.js +19 -5
  61. package/dist/sdk/terminal/tui/dialogs.d.ts +3 -2
  62. package/dist/sdk/terminal/tui/dialogs.js +4 -3
  63. package/dist/sdk/terminal/tui/keybindings.d.ts +1 -1
  64. package/dist/sdk/terminal/tui/keybindings.js +1 -1
  65. package/dist/sdk/terminal/tui/markdown.d.ts +16 -0
  66. package/dist/sdk/terminal/tui/markdown.js +55 -0
  67. package/dist/sdk/terminal/tui/modeChoices.d.ts +3 -2
  68. package/dist/sdk/terminal/tui/modeChoices.js +4 -4
  69. package/dist/sdk/terminal/tui/mouseReport.d.ts +30 -0
  70. package/dist/sdk/terminal/tui/mouseReport.js +80 -0
  71. package/dist/sdk/terminal/tui/pages/annotatePage.js +3 -2
  72. package/dist/sdk/terminal/tui/pages/browserPage.d.ts +1 -1
  73. package/dist/sdk/terminal/tui/pages/contextPage.d.ts +62 -0
  74. package/dist/sdk/terminal/tui/pages/contextPage.js +484 -0
  75. package/dist/sdk/terminal/tui/pages/remoteControlPage.d.ts +32 -0
  76. package/dist/sdk/terminal/tui/pages/remoteControlPage.js +170 -0
  77. package/dist/sdk/terminal/tui/pages/trajectoryPage.js +5 -4
  78. package/dist/sdk/terminal/tui/qr.d.ts +4 -0
  79. package/dist/sdk/terminal/tui/qr.js +35 -0
  80. package/dist/sdk/terminal/tui/runtime.js +8 -2
  81. package/dist/sdk/terminal/tui/search.js +3 -2
  82. package/dist/sdk/terminal/tui/sideQuestion.d.ts +1 -0
  83. package/dist/sdk/terminal/tui/sideQuestion.js +8 -3
  84. package/dist/sdk/terminal/tui/skillInvocation.d.ts +21 -2
  85. package/dist/sdk/terminal/tui/skillInvocation.js +31 -11
  86. package/dist/sdk/terminal/tui/status.d.ts +6 -0
  87. package/dist/sdk/terminal/tui/status.js +15 -1
  88. package/dist/sdk/terminal/tui/theme.d.ts +9 -0
  89. package/dist/sdk/terminal/tui/theme.js +9 -0
  90. package/dist/sdk/tools/defaultSkills.d.ts +19 -0
  91. package/dist/sdk/tools/defaultSkills.js +27 -1
  92. package/dist/sdk/tools/dispatch.js +8 -9
  93. package/dist/sdk/tools/spawnSubagent.d.ts +1 -1
  94. package/dist/sdk/tools/spawnSubagent.js +4 -2
  95. package/dist/terminal-opentui.js +1607 -492
  96. package/dist/terminal-pi.js +256 -144
  97. package/docs/README.md +1 -0
  98. package/docs/architecture/02-sessions-and-runs.md +55 -22
  99. package/docs/architecture/04-prompt.md +23 -4
  100. package/docs/architecture/07-tools-and-gate.md +4 -2
  101. package/docs/architecture/10-server-protocol.md +24 -0
  102. package/docs/architecture/11-terminal.md +52 -10
  103. package/docs/architecture/12-storage.md +5 -2
  104. package/docs/architecture/13-world-tools.md +17 -3
  105. package/docs/architecture/14-surfaces-and-parity.md +7 -3
  106. package/docs/architecture/15-remote-control.md +118 -0
  107. package/docs/architecture/README.md +3 -0
  108. package/docs/architecture/proposals/PROP-0049-context-attribution.md +409 -0
  109. package/docs/architecture/proposals/README.md +2 -1
  110. package/docs/decisions/ADR-0039-progress-aware-stall-guards.md +4 -0
  111. package/docs/decisions/ADR-0048-required-skills-from-a-message.md +42 -0
  112. package/docs/decisions/ADR-0050-approved-window-screenshots.md +19 -0
  113. package/docs/decisions/ADR-0051-terminal-decision-wait.md +10 -0
  114. package/docs/decisions/ADR-0052-per-profile-subagent-budgets.md +25 -0
  115. package/docs/decisions/ADR-0053-no-repeated-work-guard.md +45 -0
  116. package/docs/decisions/ADR-0054-terminal-reply-heartbeat-and-stall.md +52 -0
  117. package/docs/decisions/ADR-0055-plan-output-repair.md +51 -0
  118. package/docs/decisions/ADR-0056-remote-control.md +53 -0
  119. package/docs/decisions/ADR-0057-structured-plan-outcomes.md +39 -0
  120. package/docs/decisions/ADR-0058-ask-user-only-plan-clarification.md +36 -0
  121. package/docs/parity.md +14 -10
  122. package/docs/releases/1.3.1-unstable.3.md +54 -0
  123. package/docs/releases/1.3.1-unstable.4.md +56 -0
  124. package/docs/terminal-tui.md +43 -18
  125. package/package.json +3 -1
@@ -1,9 +1,11 @@
1
1
  import { type CallerContext } from "../core/caller.js";
2
2
  import type { HarnessEnv } from "../core/env.js";
3
3
  import type { Materialised } from "../variant/materialise.js";
4
+ import { type SkillFile } from "../tools/defaultSkills.js";
4
5
  import type { TodoItem } from "../sessions/contracts.js";
5
6
  import type { SessionState } from "../sessions/sessionState.js";
6
7
  import { type StandardSession } from "./standard.js";
8
+ export declare const REQUIRED_SKILLS_BUDGET = 12000;
7
9
  export declare const REFERENCES_BUDGET = 12000;
8
10
  /** Where the harness's own sections sit; an application's sections are placed among them by `order`. */
9
11
  export declare const SECTION_ORDER: {
@@ -13,6 +15,7 @@ export declare const SECTION_ORDER: {
13
15
  readonly guardrails: 40;
14
16
  readonly memory: 45;
15
17
  readonly references: 50;
18
+ readonly requiredSkills: 52;
16
19
  readonly subagents: 64;
17
20
  readonly scripts: 66;
18
21
  readonly wiki: 80;
@@ -24,9 +27,11 @@ export interface Source {
24
27
  id: string;
25
28
  kind: string;
26
29
  revision?: string | null;
30
+ /** Characters this source contributed inside its section, when the compiler measured them. */
31
+ chars?: number;
27
32
  }
28
33
  export interface Section {
29
- /** `harness-identity`, `identity`, `role`, `system`, `session-state`, `guardrails`, `memory`, `references`, `subagents`, `scripts`, `wiki`, `tools`, or an application's own. */
34
+ /** `harness-identity`, `identity`, `role`, `system`, `session-state`, `guardrails`, `memory`, `references`, `required-skills`, `subagents`, `scripts`, `wiki`, `tools`, or an application's own. */
30
35
  id: string;
31
36
  title: string;
32
37
  body: string;
@@ -59,6 +64,18 @@ export interface ContextPack {
59
64
  export interface Delivery {
60
65
  systemSectionIds: string[];
61
66
  updatedSectionIds: string[];
67
+ /**
68
+ * What each delivered section occupied, measured when it was rendered. Absent on packs recorded
69
+ * before the compiler measured them; a reader then falls back to the section's own size.
70
+ */
71
+ sizes?: {
72
+ system: Record<string, number>;
73
+ update: Record<string, number>;
74
+ /** Characters that belong to no section: the product heading and the context-update trailer. */
75
+ systemFraming: number;
76
+ /** Characters of the update message that are not a section, including a withdrawn one. */
77
+ updateFraming: number;
78
+ };
62
79
  }
63
80
  /** A section the embedding application writes. */
64
81
  export interface HostSection {
@@ -97,6 +114,15 @@ export type Reference = {
97
114
  label: string;
98
115
  text: string;
99
116
  };
117
+ /** A skill the person named in the message: required for this request only. */
118
+ export interface RequiredSkill {
119
+ name: string;
120
+ label?: string;
121
+ }
122
+ /** One skill's text as the required-skills section and the terminal deliver it. */
123
+ export interface RequiredSkillText extends RequiredSkill, Partial<Omit<SkillFile, 'name'>> {
124
+ body: string;
125
+ }
100
126
  /** What the compiler needs beyond the caller. */
101
127
  export interface Launch {
102
128
  /** The paragraph about connected apps, when the session has that broker. */
@@ -108,6 +134,8 @@ export interface Launch {
108
134
  /** The current session name and this run's todo, delivered as a request-scoped update. */
109
135
  sessionState?: SessionState;
110
136
  references?: Reference[];
137
+ /** Skills the message named as required for this request. Delivered as a request-scoped update. */
138
+ requiredSkills?: RequiredSkill[];
111
139
  sections?: HostSection[];
112
140
  toolGroups?: HostToolGroup[];
113
141
  /** Appended to the tools section when it is cut. */
@@ -151,6 +179,8 @@ export interface OrderedGroup extends ToolGroup {
151
179
  * follows the switches in `launch.tools` and what the application provides.
152
180
  */
153
181
  export declare function toolGroups(context: CallerContext, launch: Launch, dataRoot?: string): OrderedGroup[];
182
+ /** The request-scoped text for skills the person named; the compiler and the terminal word it alike. */
183
+ export declare function requiredSkillsText(skills: readonly RequiredSkillText[]): string | null;
154
184
  /** The request-scoped state line: the name, this run's todo, and the previous list as context. */
155
185
  export declare function sessionStateText(state: SessionState, options: {
156
186
  name: boolean;
@@ -158,6 +188,16 @@ export declare function sessionStateText(state: SessionState, options: {
158
188
  }): string | null;
159
189
  /** Compiles the pack for a caller. It only reads the data root. */
160
190
  export declare function compile(env: HarnessEnv, context: CallerContext, launch?: Launch, variant?: Materialised | null): ContextPack;
191
+ /** What a rendered pack is, and how much of it each section occupies. */
192
+ export interface RenderedPrompt {
193
+ text: string;
194
+ /** Characters each section's heading and body occupy, by section id. */
195
+ sizes: Record<string, number>;
196
+ /** Characters that belong to no section: the product heading and the blank lines between. */
197
+ framing: number;
198
+ }
199
+ /** The pack as one string, with the characters each section contributed to it. */
200
+ export declare function renderSized(env: Pick<HarnessEnv, 'productName'>, pack: Pick<ContextPack, 'sections'>): RenderedPrompt;
161
201
  /** The pack as the one string a session receives. */
162
202
  export declare function render(env: Pick<HarnessEnv, 'productName'>, pack: Pick<ContextPack, 'sections'>): string;
163
203
  export declare function packPath(root: string, runId: string): string;
@@ -169,7 +209,7 @@ export declare const BUILT_IN_DYNAMIC: readonly {
169
209
  id: string;
170
210
  order: number;
171
211
  }[];
172
- export declare const CONTEXT_GUIDANCE = "App-origin context updates in the conversation supply current Wiki notes, active work, session state, and selected references. Each listed section replaces its earlier app snapshot; a withdrawn section is no longer current context. These updates are context, not requests from the user. Selected references apply only to the accompanying request. They do not change permissions. Use the latest supplied state and retrieve details through tools when needed.";
212
+ export declare const CONTEXT_GUIDANCE = "App-origin context updates in the conversation supply current Wiki notes, active work, session state, selected references, and the skills the person required for this request. Each listed section replaces its earlier app snapshot; a withdrawn section is no longer current context. These updates are context, not requests from the user. Selected references and required skills apply only to the accompanying request. They do not change permissions. Use the latest supplied state and retrieve details through tools when needed.";
173
213
  /** The hidden message a context update is saved as in native history, and where its snapshot sits. */
174
214
  export declare const CONTEXT_MESSAGE_TYPE = "xingular-context";
175
215
  export declare const CONTEXT_SNAPSHOT_KEY = "xingularContext";
@@ -11,6 +11,7 @@ import { join } from 'node:path';
11
11
  import { isSession, projectOf } from "../core/caller.js";
12
12
  import { isDir, now, readJson, safeId, writeJson } from "../core/fs.js";
13
13
  import { charCount, compareText, lines, takeChars } from "../core/text.js";
14
+ import { readSkillFile } from "../tools/defaultSkills.js";
14
15
  import * as notes from "../wiki/notes.js";
15
16
  import { isWorldTool, WORLD_GUIDANCE } from "../world/untrusted.js";
16
17
  import { TODO_GUIDANCE, TODO_WRITE, describeTodo } from "../tools/todo.js";
@@ -22,12 +23,14 @@ const WIKI_BUDGET = 2500;
22
23
  const TOOLS_BUDGET = 8000;
23
24
  const REPORTING_BUDGET = 2000;
24
25
  const SESSION_STATE_BUDGET = 1_000;
26
+ export const REQUIRED_SKILLS_BUDGET = 12_000;
27
+ const REQUIRED_SKILLS_POINTER = 'Some required skill text was omitted to fit. Load the rest with skill_load.';
25
28
  export const REFERENCES_BUDGET = 12000;
26
29
  /** Always-on variant text is capped when the variant is saved, so its sections are never cut here. */
27
30
  const VARIANT_BUDGET = 30_000;
28
31
  const NOTES_LISTED = 25;
29
32
  /** Where the harness's own sections sit; an application's sections are placed among them by `order`. */
30
- export const SECTION_ORDER = { harnessIdentity: 5, system: 25, sessionState: 30, guardrails: 40, memory: 45, references: 50, subagents: 64, scripts: 66, wiki: 80, tools: 90, reporting: 95 };
33
+ export const SECTION_ORDER = { harnessIdentity: 5, system: 25, sessionState: 30, guardrails: 40, memory: 45, references: 50, requiredSkills: 52, subagents: 64, scripts: 66, wiki: 80, tools: 90, reporting: 95 };
31
34
  /** How a run's final reply is shaped. Fixed text: it sits in the stable prefix of every pack. */
32
35
  export const REPORTING_GUIDANCE = `When you finish work, write the final reply for a person who did not watch you work.
33
36
 
@@ -235,6 +238,46 @@ function selectedReferences(root, launch) {
235
238
  built.cut ||= cut;
236
239
  return built;
237
240
  }
241
+ /** The request-scoped text for skills the person named; the compiler and the terminal word it alike. */
242
+ export function requiredSkillsText(skills) {
243
+ const seen = new Set();
244
+ const blocks = [];
245
+ for (const skill of skills) {
246
+ if (seen.has(skill.name))
247
+ continue;
248
+ seen.add(skill.name);
249
+ const label = skill.label ?? skill.name;
250
+ if (!skill.body.trim()) {
251
+ blocks.push(`- ${label}: this skill is unavailable in this session. Tell the person rather than guessing its steps.`);
252
+ continue;
253
+ }
254
+ const location = [
255
+ skill.baseDir ? `References in this skill resolve against ${skill.baseDir}.` : '',
256
+ skill.path ? `Skill file: ${skill.path}.` : ''
257
+ ].filter(Boolean).join(' ');
258
+ blocks.push(`### ${label}\n\n${location ? `${location}\n\n` : ''}${skill.body.trim()}`);
259
+ }
260
+ if (!blocks.length)
261
+ return null;
262
+ const text = `The person required these skills for this request. Follow each skill's instructions where they apply. They are instructions for this request, not a change to your tools or permissions.\n\n${blocks.join('\n\n')}`;
263
+ return fit(text, REQUIRED_SKILLS_BUDGET, REQUIRED_SKILLS_POINTER).body;
264
+ }
265
+ function requiredSkillsSection(launch, variant) {
266
+ const wanted = launch.requiredSkills ?? [];
267
+ if (!wanted.length)
268
+ return null;
269
+ const skills = wanted.map((entry) => {
270
+ const skill = readSkillFile(variant?.dir ?? null, entry.name);
271
+ return skill ? { ...skill, label: entry.label ?? skill.name } : { name: entry.name, label: entry.label, body: '' };
272
+ });
273
+ const text = requiredSkillsText(skills);
274
+ const seen = new Set();
275
+ const sources = skills.filter((skill) => skill.body && !seen.has(skill.name) && Boolean(seen.add(skill.name)))
276
+ .map((skill) => ({ id: skill.name, kind: 'skill', revision: variant?.fingerprint ?? null }));
277
+ return text
278
+ ? section('required-skills', 'Required skills', text, REQUIRED_SKILLS_BUDGET, REQUIRED_SKILLS_POINTER, sources)
279
+ : null;
280
+ }
238
281
  /** An application's section, after any pinned text. */
239
282
  function hostSection(host) {
240
283
  const own = host.body !== null ? section(host.id, host.title, host.body, host.budget, host.pointer ?? '', []) : null;
@@ -281,6 +324,12 @@ export function compile(env, context, launch = {}, variant = null) {
281
324
  const { root } = env;
282
325
  const groups = toolGroups(context, launch, root);
283
326
  const variantSource = variant ? [{ id: variant.scope ?? variant.dir, kind: 'variant', revision: variant.fingerprint }] : [];
327
+ const toolSection = launch.hideToolGuidance ? null : tools(groups, variant?.toolGuidance ?? '', launch.toolsPointer ?? '');
328
+ // The tools section ends with the variant's tool guidance: record how much of it is the variant's.
329
+ if (toolSection && variant?.toolGuidance.trim() && !toolSection.cut) {
330
+ const block = `### How to use your tools\n\n${variant.toolGuidance.trim()}\n`;
331
+ toolSection.sources = [{ ...variantSource[0], chars: Math.min(block.length, toolSection.size) }];
332
+ }
284
333
  const built = [
285
334
  {
286
335
  order: SECTION_ORDER.harnessIdentity,
@@ -289,8 +338,9 @@ export function compile(env, context, launch = {}, variant = null) {
289
338
  ...(variant?.sections ?? []).map((entry) => ({ order: entry.order, section: section(entry.id, entry.title, entry.body, VARIANT_BUDGET, '', variantSource) })),
290
339
  { order: SECTION_ORDER.sessionState, section: sessionStateSection(launch.sessionState, launch.tools ?? []) },
291
340
  { order: SECTION_ORDER.references, section: selectedReferences(root, launch) },
341
+ { order: SECTION_ORDER.requiredSkills, section: requiredSkillsSection(launch, variant) },
292
342
  { order: SECTION_ORDER.wiki, section: wiki(root, context) },
293
- { order: SECTION_ORDER.tools, section: launch.hideToolGuidance ? null : tools(groups, variant?.toolGuidance ?? '', launch.toolsPointer ?? '') },
343
+ { order: SECTION_ORDER.tools, section: toolSection },
294
344
  { order: SECTION_ORDER.reporting, section: section('reporting', 'Reporting your work', REPORTING_GUIDANCE, REPORTING_BUDGET, '', []) },
295
345
  ...(launch.standard ? standardSections({ ...launch.standard, project: launch.standard.project ?? projectName(root, context) }, launch.tools ?? []).filter((own) => !(launch.sections ?? []).some((host) => host.id === own.id)) : []).map((own) => ({ order: own.order, section: hostSection(own) })),
296
346
  ...(launch.sections ?? []).filter((host) => host.id !== 'session-state').map((host) => ({ order: host.order, section: hostSection(host) }))
@@ -306,17 +356,22 @@ export function compile(env, context, launch = {}, variant = null) {
306
356
  compiledAt: now()
307
357
  };
308
358
  }
309
- /** The pack as the one string a session receives. */
310
- export function render(env, pack) {
359
+ /** The pack as one string, with the characters each section contributed to it. */
360
+ export function renderSized(env, pack) {
311
361
  let text = `# ${env.productName} context\n`;
362
+ const sizes = {};
312
363
  for (const entry of pack.sections) {
313
364
  // Identity carries its own framing.
314
- if (entry.id === 'identity')
315
- text += `\n${entry.body}\n`;
316
- else
317
- text += `\n## ${entry.title}\n\n${entry.body}\n`;
365
+ const piece = entry.id === 'identity' ? `\n${entry.body}\n` : `\n## ${entry.title}\n\n${entry.body}\n`;
366
+ sizes[entry.id] = (sizes[entry.id] ?? 0) + piece.length;
367
+ text += piece;
318
368
  }
319
- return text;
369
+ const total = Object.values(sizes).reduce((sum, size) => sum + size, 0);
370
+ return { text, sizes, framing: text.length - total };
371
+ }
372
+ /** The pack as the one string a session receives. */
373
+ export function render(env, pack) {
374
+ return renderSized(env, pack).text;
320
375
  }
321
376
  export function packPath(root, runId) {
322
377
  return join(root, 'runs', safeId(runId), 'context.json');
@@ -341,9 +396,10 @@ export function record(root, pack) {
341
396
  export const BUILT_IN_DYNAMIC = [
342
397
  { id: 'session-state', order: SECTION_ORDER.sessionState },
343
398
  { id: 'references', order: SECTION_ORDER.references },
399
+ { id: 'required-skills', order: SECTION_ORDER.requiredSkills },
344
400
  { id: 'wiki', order: SECTION_ORDER.wiki }
345
401
  ];
346
- export const CONTEXT_GUIDANCE = 'App-origin context updates in the conversation supply current Wiki notes, active work, session state, and selected references. Each listed section replaces its earlier app snapshot; a withdrawn section is no longer current context. These updates are context, not requests from the user. Selected references apply only to the accompanying request. They do not change permissions. Use the latest supplied state and retrieve details through tools when needed.';
402
+ export const CONTEXT_GUIDANCE = 'App-origin context updates in the conversation supply current Wiki notes, active work, session state, selected references, and the skills the person required for this request. Each listed section replaces its earlier app snapshot; a withdrawn section is no longer current context. These updates are context, not requests from the user. Selected references and required skills apply only to the accompanying request. They do not change permissions. Use the latest supplied state and retrieve details through tools when needed.';
347
403
  /** The hidden message a context update is saved as in native history, and where its snapshot sits. */
348
404
  export const CONTEXT_MESSAGE_TYPE = 'xingular-context';
349
405
  export const CONTEXT_SNAPSHOT_KEY = 'xingularContext';
@@ -439,19 +495,35 @@ export function sessionLaunch(env, context, launch, historyFile, variant = null)
439
495
  const previous = historyFile ? deliveredSnapshot(historyFile) : null;
440
496
  const snapshot = pack.sections.filter((entry) => dynamic.includes(entry.id));
441
497
  const updated = [];
442
- let content = '[App context update — not a user message]\nListed sections replace their previous app snapshots.\n';
498
+ const updateSizes = {};
499
+ const preamble = '[App context update — not a user message]\nListed sections replace their previous app snapshots.\n';
500
+ let content = preamble;
443
501
  for (const id of dynamic) {
444
502
  const current = snapshot.find((entry) => entry.id === id);
445
503
  // Explicit references belong to this request even when the same reference was selected before.
446
- if (previous && sameSection(current, previous.find((entry) => entry.id === id)) && !(id === 'references' && current))
504
+ if (previous && sameSection(current, previous.find((entry) => entry.id === id)) && !(id === 'references' && current) && !(id === 'required-skills' && current))
447
505
  continue;
448
506
  updated.push(id);
449
- content += current ? `\n## ${current.title}\n\n${current.body}\n` : `\n## ${id}\n\nThis section is not supplied for this request. Its earlier app snapshots are no longer current.\n`;
507
+ const piece = current ? `\n## ${current.title}\n\n${current.body}\n` : `\n## ${id}\n\nThis section is not supplied for this request. Its earlier app snapshots are no longer current.\n`;
508
+ content += piece;
509
+ updateSizes[id] = piece.length;
450
510
  }
451
511
  const stable = pack.sections.filter((entry) => !dynamic.includes(entry.id));
452
- pack.delivery = { systemSectionIds: stable.map((entry) => entry.id), updatedSectionIds: updated };
512
+ const rendered = renderSized(env, { sections: stable });
513
+ const system = `${rendered.text}\n## Context updates\n\n${CONTEXT_GUIDANCE}\n`;
514
+ const total = (sizes) => Object.values(sizes).reduce((sum, size) => sum + size, 0);
515
+ pack.delivery = {
516
+ systemSectionIds: stable.map((entry) => entry.id),
517
+ updatedSectionIds: updated,
518
+ sizes: {
519
+ system: rendered.sizes,
520
+ update: updated.length ? updateSizes : {},
521
+ systemFraming: system.length - total(rendered.sizes),
522
+ updateFraming: updated.length ? content.length - total(updateSizes) : 0
523
+ }
524
+ };
453
525
  record(env.root, pack);
454
- return { system: `${render(env, { sections: stable })}\n## Context updates\n\n${CONTEXT_GUIDANCE}\n`, update: updated.length ? { content, snapshot } : null, pack };
526
+ return { system, update: updated.length ? { content, snapshot } : null, pack };
455
527
  }
456
528
  // ---- Sessions that keep the instructions they started with ---------------------------------
457
529
  /** A terminal session keeps the pack it started with; each of its runs records a copy. */
@@ -0,0 +1,19 @@
1
+ import { type NetworkInterfaceInfo } from 'node:os';
2
+ export type RemoteAddressKind = 'tailscale' | 'lan';
3
+ export type RemoteInterfaceChoice = 'auto' | RemoteAddressKind;
4
+ export interface RemoteAddress {
5
+ kind: RemoteAddressKind;
6
+ address: string;
7
+ family: 'IPv4' | 'IPv6';
8
+ interface: string;
9
+ }
10
+ /** Tailscale hands out 100.64.0.0/10 and fd7a:115c:a1e0::/48. */
11
+ export declare function isTailscale(address: string): boolean;
12
+ /** RFC 1918 private IPv4 ranges. */
13
+ export declare function isPrivateLan(address: string): boolean;
14
+ /** Every address remote control may bind, Tailscale first. */
15
+ export declare function remoteAddresses(interfaces?: NodeJS.Dict<NetworkInterfaceInfo[]>): RemoteAddress[];
16
+ /** The one address to listen on for a choice, or a clear error when there is none. */
17
+ export declare function pickRemoteAddress(choice: RemoteInterfaceChoice, candidates: readonly RemoteAddress[]): RemoteAddress;
18
+ /** How a URL names the address. */
19
+ export declare function hostPart(address: RemoteAddress): string;
@@ -0,0 +1,52 @@
1
+ // Where remote control may listen: a Tailscale address or a private LAN address, never a public
2
+ // one, never every interface (INV-RC-1).
3
+ import { networkInterfaces } from 'node:os';
4
+ import { HarnessError } from "../core/errors.js";
5
+ const octets = (address) => address.split('.').map(Number);
6
+ /** Tailscale hands out 100.64.0.0/10 and fd7a:115c:a1e0::/48. */
7
+ export function isTailscale(address) {
8
+ if (address.includes(':'))
9
+ return address.toLowerCase().startsWith('fd7a:115c:a1e0:');
10
+ const [a, b] = octets(address);
11
+ return a === 100 && b >= 64 && b <= 127;
12
+ }
13
+ /** RFC 1918 private IPv4 ranges. */
14
+ export function isPrivateLan(address) {
15
+ if (address.includes(':'))
16
+ return false;
17
+ const [a, b] = octets(address);
18
+ return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
19
+ }
20
+ /** Every address remote control may bind, Tailscale first. */
21
+ export function remoteAddresses(interfaces = networkInterfaces()) {
22
+ const found = [];
23
+ for (const [name, entries] of Object.entries(interfaces)) {
24
+ for (const entry of entries ?? []) {
25
+ if (entry.internal)
26
+ continue;
27
+ const family = entry.family === 'IPv6' ? 'IPv6' : 'IPv4';
28
+ if (isTailscale(entry.address))
29
+ found.push({ kind: 'tailscale', address: entry.address, family, interface: name });
30
+ else if (isPrivateLan(entry.address))
31
+ found.push({ kind: 'lan', address: entry.address, family, interface: name });
32
+ }
33
+ }
34
+ // IPv4 first within a kind: it is what a person can type and what every phone resolves.
35
+ const rank = (address) => (address.kind === 'tailscale' ? 0 : 2) + (address.family === 'IPv4' ? 0 : 1);
36
+ return found.sort((a, b) => rank(a) - rank(b));
37
+ }
38
+ /** The one address to listen on for a choice, or a clear error when there is none. */
39
+ export function pickRemoteAddress(choice, candidates) {
40
+ const pick = choice === 'auto' ? candidates[0] : candidates.find((candidate) => candidate.kind === choice);
41
+ if (pick)
42
+ return pick;
43
+ if (choice === 'tailscale')
44
+ throw new HarnessError('remote_unavailable', 'No Tailscale address found. Start Tailscale on this computer, or use the Wi-Fi address instead.');
45
+ if (choice === 'lan')
46
+ throw new HarnessError('remote_unavailable', 'No private Wi-Fi or LAN address found. Join a network, or use Tailscale instead.');
47
+ throw new HarnessError('remote_unavailable', 'No Tailscale or private network address found. Join a Wi-Fi network or start Tailscale, then try /remote-control again.');
48
+ }
49
+ /** How a URL names the address. */
50
+ export function hostPart(address) {
51
+ return address.family === 'IPv6' ? `[${address.address}]` : address.address;
52
+ }
@@ -0,0 +1,59 @@
1
+ import { ReplayGuard } from "./seal.js";
2
+ export declare const MAX_DEVICES = 3;
3
+ /** Under a minute: a phone's browser gives up on a request it waits on much longer. */
4
+ export declare const CONFIRM_MS = 50000;
5
+ export interface RemoteDevice {
6
+ id: string;
7
+ label: string;
8
+ key: Uint8Array;
9
+ pairedAt: number;
10
+ lastSeen: number;
11
+ /** The counters this device has sent. */
12
+ replay: ReplayGuard;
13
+ /** The counter of the last frame sent to it. */
14
+ lastOut: number;
15
+ }
16
+ export interface PairRequest {
17
+ id: string;
18
+ label: string;
19
+ at: number;
20
+ }
21
+ /** A short, printable name for a browser from its user agent. Nothing from the phone is trusted. */
22
+ export declare function deviceLabel(userAgent: unknown): string;
23
+ export declare class Pairing {
24
+ private readonly changed;
25
+ private readonly clock;
26
+ private link;
27
+ private linkReplay;
28
+ private pending;
29
+ private readonly devices;
30
+ private hellos;
31
+ private failures;
32
+ private notice;
33
+ constructor(changed?: () => void, clock?: () => number);
34
+ private newLink;
35
+ /** What follows `#` in the pairing URL. */
36
+ get fragment(): string;
37
+ get request(): PairRequest | null;
38
+ /** A warning for the terminal page, such as a link replaced after repeated bad attempts. */
39
+ get warning(): string | null;
40
+ list(): RemoteDevice[];
41
+ device(deviceId: string): RemoteDevice | null;
42
+ /** A new link; every paired device is dropped and a waiting request denied. */
43
+ rotate(): void;
44
+ forget(deviceId: string): void;
45
+ /** The person at the terminal allowed or refused a waiting request. */
46
+ confirm(requestId: string, allow: boolean): void;
47
+ private fail;
48
+ /**
49
+ * A phone opened the pairing link. Waits until the person at the terminal decides, then returns
50
+ * the device's id and key, sealed with the link key.
51
+ */
52
+ hello(pairingId: unknown, frame: unknown, confirmMs?: number): Promise<string>;
53
+ /** Opens a paired device's frame, enforcing its counter. */
54
+ openFrom<T = unknown>(deviceId: unknown, frame: unknown): {
55
+ device: RemoteDevice;
56
+ body: T;
57
+ };
58
+ sealFor(device: RemoteDevice, body: unknown): string;
59
+ }
@@ -0,0 +1,160 @@
1
+ // Pairing for remote control. The QR code carries a pairing link: an id and a key, in the URL's
2
+ // fragment. A phone that opens it says hello, sealed with that key; the person at the terminal
3
+ // confirms; the phone receives its own device key. A link pairs one device and is then replaced,
4
+ // so the QR on screen always pairs the next one. Every key lives in memory only (INV-RC-2).
5
+ import { randomBytes } from 'node:crypto';
6
+ import { HarnessError } from "../core/errors.js";
7
+ import { encode, newKey, open, ReplayGuard, sameSecret, seal } from "./seal.js";
8
+ export const MAX_DEVICES = 3;
9
+ const HELLO_WINDOW_MS = 60_000;
10
+ const MAX_HELLOS = 10;
11
+ const MAX_FAILURES = 20;
12
+ /** Under a minute: a phone's browser gives up on a request it waits on much longer. */
13
+ export const CONFIRM_MS = 50_000;
14
+ /** A short, printable name for a browser from its user agent. Nothing from the phone is trusted. */
15
+ export function deviceLabel(userAgent) {
16
+ const ua = typeof userAgent === 'string' ? userAgent.slice(0, 400) : '';
17
+ const device = /iPad/.test(ua) ? 'iPad'
18
+ : /iPhone/.test(ua) ? 'iPhone'
19
+ : /Android/.test(ua) ? 'Android'
20
+ : /Macintosh|Mac OS X/.test(ua) ? (/Mobile/.test(ua) ? 'iPad' : 'Mac')
21
+ : /Windows/.test(ua) ? 'Windows'
22
+ : /Linux/.test(ua) ? 'Linux' : 'Browser';
23
+ const browser = /EdgiOS|EdgA|Edg\//.test(ua) ? 'Edge'
24
+ : /CriOS|Chrome\//.test(ua) ? 'Chrome'
25
+ : /FxiOS|Firefox\//.test(ua) ? 'Firefox'
26
+ : /Safari\//.test(ua) ? 'Safari' : '';
27
+ return browser ? `${device} · ${browser}` : device;
28
+ }
29
+ const id = () => randomBytes(12).toString('base64url');
30
+ export class Pairing {
31
+ changed;
32
+ clock;
33
+ link = { id: id(), key: newKey() };
34
+ linkReplay = new ReplayGuard();
35
+ pending = null;
36
+ devices = new Map();
37
+ hellos = [];
38
+ failures = 0;
39
+ notice = null;
40
+ constructor(changed = () => { }, clock = Date.now) {
41
+ this.changed = changed;
42
+ this.clock = clock;
43
+ }
44
+ newLink() {
45
+ this.link = { id: id(), key: newKey() };
46
+ this.linkReplay = new ReplayGuard();
47
+ }
48
+ /** What follows `#` in the pairing URL. */
49
+ get fragment() {
50
+ return `${this.link.id}.${encode(this.link.key)}`;
51
+ }
52
+ get request() {
53
+ return this.pending ? { id: this.pending.id, label: this.pending.label, at: this.pending.at } : null;
54
+ }
55
+ /** A warning for the terminal page, such as a link replaced after repeated bad attempts. */
56
+ get warning() {
57
+ return this.notice;
58
+ }
59
+ list() {
60
+ return [...this.devices.values()];
61
+ }
62
+ device(deviceId) {
63
+ for (const [known, device] of this.devices)
64
+ if (sameSecret(known, deviceId))
65
+ return device;
66
+ return null;
67
+ }
68
+ /** A new link; every paired device is dropped and a waiting request denied. */
69
+ rotate() {
70
+ this.newLink();
71
+ this.pending?.decide(false);
72
+ this.pending = null;
73
+ this.devices.clear();
74
+ this.failures = 0;
75
+ this.notice = null;
76
+ this.changed();
77
+ }
78
+ forget(deviceId) {
79
+ const device = this.device(deviceId);
80
+ if (device && this.devices.delete(device.id))
81
+ this.changed();
82
+ }
83
+ /** The person at the terminal allowed or refused a waiting request. */
84
+ confirm(requestId, allow) {
85
+ if (!this.pending || this.pending.id !== requestId)
86
+ throw new HarnessError('invalid_request', 'This pairing request is no longer waiting.');
87
+ this.pending.decide(allow);
88
+ }
89
+ fail() {
90
+ this.failures += 1;
91
+ if (this.failures >= MAX_FAILURES) {
92
+ this.newLink();
93
+ this.failures = 0;
94
+ this.notice = 'Too many failed pairing attempts. The link was replaced; scan the new QR code.';
95
+ this.changed();
96
+ }
97
+ return new HarnessError('unauthorized', 'This pairing link is not valid. Scan the QR code in the terminal again.');
98
+ }
99
+ /**
100
+ * A phone opened the pairing link. Waits until the person at the terminal decides, then returns
101
+ * the device's id and key, sealed with the link key.
102
+ */
103
+ async hello(pairingId, frame, confirmMs = CONFIRM_MS) {
104
+ const at = this.clock();
105
+ this.hellos = this.hellos.filter((time) => at - time < HELLO_WINDOW_MS);
106
+ if (this.hellos.length >= MAX_HELLOS)
107
+ throw new HarnessError('rate_limited', 'Too many pairing attempts. Wait a minute and try again.');
108
+ this.hellos.push(at);
109
+ if (typeof pairingId !== 'string' || typeof frame !== 'string' || !sameSecret(pairingId, this.link.id))
110
+ throw this.fail();
111
+ const link = this.link;
112
+ let body;
113
+ try {
114
+ body = open(link.key, frame, (n) => this.linkReplay.accept(n)).body ?? {};
115
+ }
116
+ catch {
117
+ throw this.fail();
118
+ }
119
+ if (this.pending)
120
+ throw new HarnessError('remote_busy', 'Another device is waiting for approval in the terminal.');
121
+ if (this.devices.size >= MAX_DEVICES)
122
+ throw new HarnessError('remote_full', `At most ${MAX_DEVICES} devices can control a session. Press n in /remote-control to start over.`);
123
+ const label = deviceLabel(body.userAgent);
124
+ const allowed = await new Promise((resolve) => {
125
+ const timer = setTimeout(() => decide(false), confirmMs);
126
+ timer.unref?.();
127
+ const decide = (allow) => {
128
+ clearTimeout(timer);
129
+ if (this.pending?.decide === decide)
130
+ this.pending = null;
131
+ resolve(allow);
132
+ this.changed();
133
+ };
134
+ this.pending = { id: id(), label, at, decide };
135
+ this.changed();
136
+ });
137
+ // A rotation while the phone waited replaced this link; its request is void.
138
+ if (!allowed || this.link !== link)
139
+ throw new HarnessError('remote_denied', 'Pairing was not allowed at the terminal.');
140
+ const device = { id: id(), label, key: newKey(), pairedAt: this.clock(), lastSeen: this.clock(), replay: new ReplayGuard(), lastOut: 0 };
141
+ this.devices.set(device.id, device);
142
+ // The link paired its device; the QR on screen now pairs the next one.
143
+ this.newLink();
144
+ this.changed();
145
+ return seal(link.key, 1, { deviceId: device.id, deviceKey: encode(device.key), label });
146
+ }
147
+ /** Opens a paired device's frame, enforcing its counter. */
148
+ openFrom(deviceId, frame) {
149
+ const device = typeof deviceId === 'string' ? this.device(deviceId) : null;
150
+ if (!device || typeof frame !== 'string')
151
+ throw new HarnessError('unauthorized', 'This device is not paired with the session. Scan the QR code in the terminal again.');
152
+ const opened = open(device.key, frame, (n) => device.replay.accept(n));
153
+ device.lastSeen = this.clock();
154
+ return { device, body: opened.body };
155
+ }
156
+ sealFor(device, body) {
157
+ device.lastOut += 1;
158
+ return seal(device.key, device.lastOut, body);
159
+ }
160
+ }
@@ -0,0 +1,28 @@
1
+ export declare const KEY_BYTES: number;
2
+ /** How far behind the highest counter a frame may arrive, for requests a phone sent at once. */
3
+ export declare const REPLAY_WINDOW = 256;
4
+ export interface Opened<T> {
5
+ n: number;
6
+ body: T;
7
+ }
8
+ export declare function newKey(): Uint8Array;
9
+ /** URL-safe base64 without padding: it travels in fragments, query strings and SSE lines. */
10
+ export declare function encode(bytes: Uint8Array): string;
11
+ export declare function decode(text: string): Uint8Array;
12
+ export declare function seal(key: Uint8Array, n: number, body: unknown): string;
13
+ /**
14
+ * Opens a frame and asks `fresh` whether its counter is new. A frame that fails here is a wrong
15
+ * key, a tampered body, or a replay; callers answer all three the same way.
16
+ */
17
+ export declare function open<T = unknown>(key: Uint8Array, frame: string, fresh: (n: number) => boolean): Opened<T>;
18
+ /**
19
+ * Remembers the counters a sender used: a counter is fresh when it is above the highest seen, or
20
+ * within the window below it and not seen before.
21
+ */
22
+ export declare class ReplayGuard {
23
+ private highest;
24
+ private readonly recent;
25
+ accept(n: number): boolean;
26
+ }
27
+ /** Compares two identifiers without leaking how much of them matched. */
28
+ export declare function sameSecret(a: string, b: string): boolean;