@xingular/harness 2.0.0 → 2.1.1

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.
@@ -18,7 +18,7 @@ function connectionFromEnv(env = process.env) {
18
18
  if (!socket || !token) throw new SocketError("not_configured", `This command only works inside a terminal session the harness started (${SOCKET_ENV} and ${TOKEN_ENV} are not set).`);
19
19
  return { socket, token };
20
20
  }
21
- function request(connection, body, what, timeoutMs) {
21
+ function request(connection, body2, what, timeoutMs) {
22
22
  return new Promise((resolve, reject) => {
23
23
  const stream = createConnection(connection.socket);
24
24
  let buffer = "";
@@ -34,7 +34,7 @@ function request(connection, body, what, timeoutMs) {
34
34
  stream.destroy();
35
35
  finish(() => reject(new SocketError("timeout", `The harness did not answer ${what}.`)));
36
36
  }, timeoutMs);
37
- stream.on("connect", () => stream.write(`${JSON.stringify({ token: connection.token, ...body })}
37
+ stream.on("connect", () => stream.write(`${JSON.stringify({ token: connection.token, ...body2 })}
38
38
  `));
39
39
  stream.on("data", (chunk) => {
40
40
  buffer += chunk.toString("utf8");
@@ -177,13 +177,13 @@ async function addAccount(ui, call) {
177
177
  if (await attempt(ui, () => call("accounts.create", { label: label.trim(), authKind: "subscription" }))) ui.notify("Account added. Sign in to use it.", "info");
178
178
  }
179
179
  async function accountMenu(ui, call, session, accountId) {
180
- const isSession = session.accountId === accountId;
180
+ const isSession2 = session.accountId === accountId;
181
181
  for (; ; ) {
182
182
  const overview = await call("accounts.list");
183
183
  const account = overview.accounts.find((candidate) => candidate.id === accountId);
184
184
  if (!account) return;
185
185
  const actions = [
186
- ...isSession ? [] : [USE_HERE],
186
+ ...isSession2 ? [] : [USE_HERE],
187
187
  ...account.isDefault ? [] : [MAKE_DEFAULT],
188
188
  RENAME,
189
189
  CHECK,
@@ -193,7 +193,7 @@ async function accountMenu(ui, call, session, accountId) {
193
193
  ...account.state.piProviders.length ? [REMOVE_PROVIDER] : [],
194
194
  SIGN_OUT,
195
195
  // The session's own account is in use, so it cannot be removed from inside the session.
196
- ...isSession ? [] : [REMOVE],
196
+ ...isSession2 ? [] : [REMOVE],
197
197
  BACK
198
198
  ];
199
199
  const action = await ui.select(`${account.label} \xB7 ${HEALTH[account.state.health] ?? HEALTH.unknown}`, actions);
@@ -278,16 +278,16 @@ async function followLogin(ui, call, accountId, loginId) {
278
278
  const deadline = Date.now() + LOGIN_TIMEOUT_MS;
279
279
  let shownRevision = -1;
280
280
  for (; ; ) {
281
- const snapshot = await call("accounts.loginStatus", { accountId });
282
- if (!snapshot || snapshot.loginId !== loginId) return;
283
- if (snapshot.status !== "running") return reportLogin(ui, snapshot);
284
- if (snapshot.prompt) {
285
- await answerPrompt(ui, call, loginId, snapshot.prompt);
281
+ const snapshot2 = await call("accounts.loginStatus", { accountId });
282
+ if (!snapshot2 || snapshot2.loginId !== loginId) return;
283
+ if (snapshot2.status !== "running") return reportLogin(ui, snapshot2);
284
+ if (snapshot2.prompt) {
285
+ await answerPrompt(ui, call, loginId, snapshot2.prompt);
286
286
  continue;
287
287
  }
288
- const step = [...snapshot.events].reverse().map(loginStep).find((text3) => text3 !== null);
289
- if (step && snapshot.revision !== shownRevision) {
290
- shownRevision = snapshot.revision;
288
+ const step = [...snapshot2.events].reverse().map(loginStep).find((text3) => text3 !== null);
289
+ if (step && snapshot2.revision !== shownRevision) {
290
+ shownRevision = snapshot2.revision;
291
291
  const choice = await ui.select(`${step}
292
292
 
293
293
  Finish in your browser, then choose Check status.`, [CHECK_STATUS, CANCEL_SIGN_IN]);
@@ -306,10 +306,10 @@ Finish in your browser, then choose Check status.`, [CHECK_STATUS, CANCEL_SIGN_I
306
306
  await new Promise((resolve) => setTimeout(resolve, POLL_MS));
307
307
  }
308
308
  }
309
- function reportLogin(ui, snapshot) {
310
- if (snapshot.status === "completed") ui.notify("Signed in.", "info");
311
- else if (snapshot.status === "cancelled") ui.notify("Sign-in was cancelled.", "warning");
312
- else ui.notify(snapshot.error ?? "Sign-in failed.", "error");
309
+ function reportLogin(ui, snapshot2) {
310
+ if (snapshot2.status === "completed") ui.notify("Signed in.", "info");
311
+ else if (snapshot2.status === "cancelled") ui.notify("Sign-in was cancelled.", "warning");
312
+ else ui.notify(snapshot2.error ?? "Sign-in failed.", "error");
313
313
  }
314
314
  async function answerPrompt(ui, call, loginId, prompt) {
315
315
  const promptId = String(prompt.promptId ?? "");
@@ -838,6 +838,59 @@ function askUserTool(run2) {
838
838
  };
839
839
  }
840
840
 
841
+ // src/core/frontmatter.ts
842
+ import { parse as parseYaml, stringify } from "yaml";
843
+
844
+ // src/memory/store.ts
845
+ import { stringify as stringify2 } from "yaml";
846
+
847
+ // src/skills/registry.ts
848
+ var MAX_SKILL_IMPORT_BYTES = 10 * 1024 * 1024;
849
+
850
+ // src/prompt/compile.ts
851
+ var SECTION_ORDER = { blocks: 30, rules: 40, references: 50, skills: 60, knowledge: 80, tools: 90 };
852
+ var BUILT_IN_DYNAMIC = [
853
+ { id: "blocks", order: SECTION_ORDER.blocks },
854
+ { id: "references", order: SECTION_ORDER.references },
855
+ { id: "knowledge", order: SECTION_ORDER.knowledge }
856
+ ];
857
+
858
+ // src/sessions/planMode.ts
859
+ var PLAN_READ_ONLY_TOOLS = /* @__PURE__ */ new Set([
860
+ "read",
861
+ "grep",
862
+ "find",
863
+ "ls",
864
+ ASK_USER,
865
+ "ask_user_question",
866
+ "history_search",
867
+ "history_read",
868
+ "trajectory_search",
869
+ "trajectory_read",
870
+ "wiki_entrypoint",
871
+ "wiki_search",
872
+ "wiki_read",
873
+ "wiki_list",
874
+ "memory_facts",
875
+ "skill_load",
876
+ "mcp_list_tools"
877
+ ]);
878
+ var PLAN_OPEN_TAG = "<proposed_plan>";
879
+ var PLAN_CLOSE_TAG = "</proposed_plan>";
880
+ var PLAN_INSTRUCTIONS = `Plan mode is on for this request. Investigate and design; do not carry out the work.
881
+ - Read, search and recall freely. When a decision belongs to the user, ask with ${ASK_USER} instead of guessing.
882
+ - Do not change files, run commands, write memory, delegate, or call connected apps. Such calls are refused while plan mode is on.
883
+ - When the plan is ready, end the reply with exactly one block in this form, after any other text:
884
+ ${PLAN_OPEN_TAG}
885
+ # <Plan title>
886
+ <Markdown: the context, the steps with the files they change, and how to verify the result>
887
+ ${PLAN_CLOSE_TAG}
888
+ - If you need the user's input before a plan is possible, ask instead of proposing one.`;
889
+ function planRefusal(tool, readOnly) {
890
+ if (PLAN_READ_ONLY_TOOLS.has(tool) || readOnly.has(tool)) return null;
891
+ return `Plan mode is read-only: ${tool} is not available until the user leaves plan mode. Continue investigating, or finish with a ${PLAN_OPEN_TAG} block.`;
892
+ }
893
+
841
894
  // src/terminal/piExtension.ts
842
895
  function resultSize(result) {
843
896
  const content = result?.content;
@@ -857,7 +910,7 @@ async function withParentCancellation(signal, work) {
857
910
  signal?.removeEventListener("abort", stop);
858
911
  }
859
912
  }
860
- async function piExtension_default(pi) {
913
+ async function piExtension_default(pi, planRun) {
861
914
  await terminalCall("startup", { phase: "Pi extension loaded" });
862
915
  let context = null;
863
916
  let nativeSubagents = false;
@@ -982,6 +1035,10 @@ async function piExtension_default(pi) {
982
1035
  await controlCall("terminal.subagents.register", { tools: pi.getActiveTools(), model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : null, effort: pi.getThinkingLevel(), history: subagentHistory(ctx.sessionManager.getBranch()) });
983
1036
  subagentParent = true;
984
1037
  }
1038
+ if (planRun && event.messageType === "user") {
1039
+ if (planRun.active) event.systemPromptOptions.sections.plan = PLAN_INSTRUCTIONS;
1040
+ else delete event.systemPromptOptions.sections.plan;
1041
+ }
985
1042
  });
986
1043
  const deliverChildren = async (event, ctx, settle) => {
987
1044
  if (!nativeSubagents || settle && event.outcome !== "completed" || ctx.signal?.aborted) return;
@@ -1039,6 +1096,10 @@ async function piExtension_default(pi) {
1039
1096
  requestStartedAt = Date.now();
1040
1097
  });
1041
1098
  pi.on("tool_call", async (event) => {
1099
+ if (planRun?.active && !planRun.allowedTools.has(event.toolName)) {
1100
+ const reason = planRefusal(event.toolName, /* @__PURE__ */ new Set()) ?? `Plan mode is read-only: ${event.toolName} is not available in this session.`;
1101
+ return { block: true, reason };
1102
+ }
1042
1103
  try {
1043
1104
  const policy = await controlCall("terminal.policy", { tool: event.toolName, args: event.input });
1044
1105
  if (policy.requiresApproval) {
@@ -59,10 +59,12 @@ recovery windows. Real scripted Pi tests in `conversation.test.ts` cover async d
59
59
  receipts and interruption during a child question. `runtime.socket.test.ts` exercises the real
60
60
  terminal socket, private controls, native execution and child decision routing. RPC and App host
61
61
  regressions cover protocol shapes and ancestry. Tests use disposable storage without external
62
- model requests. These are working-tree changes, not a publication or live-provider acceptance claim.
62
+ model requests. These changes shipped in `@xingular/harness` 2.0.0 (commit `44471bc`); release does not imply live-provider acceptance.
63
63
 
64
64
  Verification on 2026-10-18: Harness typecheck, 455 tests (two skipped), unchanged golden fixtures,
65
65
  build and standalone proof passed. App typecheck, layer/UI checks, sidecar build, 30 sidecar tests
66
66
  and 217 Rust tests passed (one ignored). The renderer suite passed 491 tests and retained its
67
67
  pre-existing `PendingRequestDock.test.ts` heading-text assertion failure. No live account or
68
68
  user-data-root testing was performed.
69
+
70
+ Release verification on 2026-10-18: npm 10 clean install, typecheck, 515 tests (two skipped), build and standalone proof passed. GitHub CI passed on Ubuntu and macOS, and the npm publish workflow released version 2.0.0 with provenance. No live provider requests were made during release verification.
@@ -87,16 +87,22 @@ The decision and its consequences are recorded in
87
87
 
88
88
  ## Plan mode and thinking keys
89
89
 
90
- Shift+Tab switches the terminal between normal and plan mode. The switch is announced in the
91
- transcript. Plan mode narrows the session to read-only inspection (`read`, `grep`, `find`, `ls`),
92
- questions (`ask_user`, `ask_user_question`) and recall (history and Wiki reads, `memory_facts`). Edits,
93
- writes, shell commands, memory writes, subagents and connected-app tools are withdrawn until plan mode
94
- is switched off, and switching back restores the tools the session had. Plan mode belongs to the open
95
- conversation: a new or resumed session starts in normal mode.
96
-
97
- The model is not yet told about the mode through the prompt; it only sees the narrower tool set. That
98
- instruction belongs in the prompt compiler, the one place model instructions are built. Shift+Up and
99
- Shift+Down step the thinking level within the levels the model supports, and do not wrap.
90
+ Use `/plan <brief>` for one read-only planning request. A plan waits outside Pi's native follow-up
91
+ queue until the active response settles, then runs by itself. Later messages remain behind it, even
92
+ when Pi's follow-up mode is `all`. During that run the session narrows to read-only inspection
93
+ (`read`, `grep`, `find`, `ls`), questions (`ask_user`, `ask_user_question`) and recall (history and
94
+ Wiki reads, `memory_facts`); edits, writes, shell commands, memory writes, subagents and connected-app
95
+ tools are blocked. The plan instructions arrive as a request-scoped prompt section, which the next
96
+ ordinary request withdraws. `/plan` without a brief and the old on/off/toggle forms are rejected.
97
+
98
+ While typing, a recognized leading slash command uses the selected theme's link color. This
99
+ includes built-in commands, loaded extension and prompt commands, and enabled skill invocations
100
+ written as `/skill:name`. Unknown slash tokens keep the normal text color.
101
+
102
+ SDK session runs receive plan instructions as a dynamic context update, leaving the stable system
103
+ prompt unchanged; their session gate refuses calls outside the read-only set. The standalone
104
+ terminal applies the same one-run instructions and tool restrictions. Shift+Up and Shift+Down step
105
+ the thinking level within the levels the model supports, and do not wrap.
100
106
 
101
107
  ## Reference and reproduced presentation
102
108
 
@@ -302,9 +308,9 @@ The [live report and capture](evidence/terminal-2026-10-17-followup/live-codex/R
302
308
  this quota failure explicitly. Successful live streaming/tool acceptance remains open until
303
309
  account capacity is available. GitHub gist publication, actual OS clipboard/image transfer,
304
310
  Windows behavior and every terminal emulator remain unverified. Rich image/diff rendering and clipboard adapters have
305
- focused compatibility tests; that does not establish OS interaction. These working-tree changes
306
- are not a published release and the complete plan's acceptance remains open while required
307
- integration/provider evidence is outstanding.
311
+ focused compatibility tests; that does not establish OS interaction. These changes shipped in
312
+ `@xingular/harness` 2.0.0 (commit `44471bc`), while the complete plan's acceptance remains
313
+ open pending the required integration and provider evidence.
308
314
 
309
315
  To reproduce the controlled scenario after building:
310
316
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xingular/harness",
3
- "version": "2.0.0",
3
+ "version": "2.1.1",
4
4
  "type": "module",
5
5
  "description": "Xingular Harness — a Pi agent harness with multi-account sign-in, a compiled system prompt, a memory lifecycle (Wiki), tools, MCP, and skills",
6
6
  "repository": {
@@ -40,6 +40,8 @@ describe('Hub installation', () => {
40
40
  expect(existsSync(join(root, 'config', 'plugin-attachments.json'))).toBe(false)
41
41
  writeFileSync(join(root, 'hub', 'documents', receipt.document!), '{"edited":true}')
42
42
  expect(() => hub.readDocument('example/review', '1.0.0')).toThrow('differs')
43
+ expect((await hub.list()).installed[0]).toMatchObject({ state: 'changed' })
44
+ await expect(hub.install('example/review', '1.0.0')).rejects.toThrow('Uninstall it before installing')
43
45
  })
44
46
  it('imports complete skills disabled and refuses overwriting local files', async () => {
45
47
  const { hub, root } = fixture([entry('registry', skill())])
@@ -90,12 +92,58 @@ describe('Hub installation', () => {
90
92
  expect(preview.delivery.kind !== 'pi-package' && preview.delivery.bundled).toContain('data only')
91
93
  expect(existsSync(join(root, 'hub'))).toBe(false)
92
94
  })
95
+ it('returns bounded UTF-8-safe preview pages and reuses the verified download for install', async () => {
96
+ const content = `"${'é'.repeat(20_000)}"`
97
+ const item = entry('document', content)
98
+ if (item.delivery.kind !== 'pi-package') delete item.delivery.bundled
99
+ const fetcher = vi.fn(async () => new Response(content)) as typeof fetch
100
+ const { hub, root } = fixture([item], fetcher)
101
+ const pages = []
102
+ let offset = 0
103
+ while (true) {
104
+ const page = await hub.preview(item.id, item.version, offset)
105
+ expect(Buffer.byteLength(page.content)).toBeLessThanOrEqual(16 * 1024)
106
+ pages.push(page.content)
107
+ if (page.done) break
108
+ offset = page.nextOffset
109
+ }
110
+ expect(pages.join('')).toBe(content)
111
+ expect(fetcher).toHaveBeenCalledTimes(1)
112
+ await hub.install(item.id, item.version)
113
+ expect(fetcher).toHaveBeenCalledTimes(1)
114
+ expect(existsSync(join(root, 'hub', 'documents'))).toBe(true)
115
+ })
116
+ it('uninstalls only its receipt-owned document, including local edits', async () => {
117
+ const first = entry()
118
+ const second = { ...entry(), id: 'example/second', title: 'Second' }
119
+ const { hub, root } = fixture([first, second])
120
+ const owned = await hub.install(first.id, first.version)
121
+ const other = await hub.install(second.id, second.version)
122
+ writeFileSync(join(root, 'hub', 'documents', owned.document!), '{"local":true}')
123
+ await hub.uninstall(first.id, first.version)
124
+ expect(existsSync(join(root, 'hub', 'documents', owned.document!))).toBe(false)
125
+ expect(existsSync(join(root, 'hub', 'documents', other.document!))).toBe(true)
126
+ expect((await hub.list()).installed.map((receipt) => receipt.entry.id)).toEqual([second.id])
127
+ })
128
+ it('uninstalls only receipt-owned registry resources and leaves unrelated files', async () => {
129
+ const { hub, root } = fixture([entry('registry', skill())])
130
+ const receipt = await hub.install('example/review', '1.0.0')
131
+ const unrelated = join(root, 'registry', 'skills', 'unrelated', 'SKILL.md')
132
+ mkdirSync(join(root, 'registry', 'skills', 'unrelated'), { recursive: true })
133
+ writeFileSync(unrelated, '---\nname: unrelated\ndescription: Local.\n---\n')
134
+ await hub.uninstall('example/review', '1.0.0')
135
+ expect(existsSync(join(root, 'registry', 'skills', 'review'))).toBe(false)
136
+ expect(readFileSync(unrelated, 'utf8')).toContain('unrelated')
137
+ expect((await hub.list()).installed).toEqual([])
138
+ expect(receipt.resourceIds).toEqual(['skills/review'])
139
+ })
93
140
  it('can reinstall deleted resources and clears stale attachment grants', async () => {
94
141
  const { hub, root } = fixture([entry('registry', skill())])
95
142
  await hub.install('example/review', '1.0.0')
96
143
  registry.setEnabled(root, null, ['skills/review'])
97
144
  rmSync(join(root, 'registry', 'skills', 'review'), { recursive: true })
98
- expect((await hub.list()).installed).toEqual([])
145
+ expect((await hub.list()).installed[0]).toMatchObject({ state: 'incomplete' })
146
+ await hub.uninstall('example/review', '1.0.0')
99
147
  await hub.install('example/review', '1.0.0')
100
148
  expect(registry.enabledFor(root, null)).toEqual([])
101
149
  expect((await hub.list()).installed).toHaveLength(1)
package/src/hub/hub.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto'
2
- import { closeSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { closeSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
3
3
  import { join } from 'node:path'
4
4
  import { watch } from 'node:fs'
5
5
 
@@ -12,7 +12,7 @@ import * as registry from '../skills/registry'
12
12
  import * as attachments from '../plugins/attachments'
13
13
  import { version as harnessVersion } from '../../package.json'
14
14
  import bundled from './catalog.json'
15
- import { HUB_CATALOG_URL, HUB_MAX_BYTES, hubCatalogSchema, hubEntrySchema, hubReceiptSchema, meetsMinimum, type HubCatalog, type HubEntry, type HubOverview, type HubReceipt } from './schema'
15
+ import { HUB_CATALOG_URL, HUB_MAX_BYTES, HUB_PREVIEW_PAGE_BYTES, hubCatalogSchema, hubEntrySchema, hubReceiptSchema, meetsMinimum, type HubCatalog, type HubEntry, type HubOverview, type HubReceipt } from './schema'
16
16
 
17
17
  export interface HubOptions {
18
18
  catalogUrl?: string
@@ -60,6 +60,7 @@ export class Hub {
60
60
  private watcher: ReturnType<typeof watch> | null = null
61
61
  private refresh: Promise<HubOverview> | null = null
62
62
  private installing = false
63
+ private cachedPayload: { key: string; bytes: Buffer; expiresAt: number } | null = null
63
64
  get busy(): boolean { return this.installing }
64
65
 
65
66
  constructor(private readonly harness: Harness, options: HubOptions = {}) {
@@ -80,12 +81,25 @@ export class Hub {
80
81
  const cached = readJsonOr<{ catalog: unknown; at: string } | null>(join(this.harness.stateDir, 'hub', 'catalog.json'), null)
81
82
  const parsed = hubCatalogSchema.safeParse(cached?.catalog)
82
83
  const catalog = parsed.success ? parsed.data : this.fallback
83
- const installed = this.receipts().filter((receipt) => this.present(receipt))
84
+ const installed = this.receipts().map((receipt) => ({ ...receipt, state: this.receiptState(receipt) }))
84
85
  const entries = new Map(catalog.entries.map((entry) => [`${entry.id}@${entry.version}`, entry]))
85
86
  for (const receipt of installed) if (!entries.has(`${receipt.entry.id}@${receipt.entry.version}`)) entries.set(`${receipt.entry.id}@${receipt.entry.version}`, receipt.entry)
86
87
  return { entries: [...entries.values()], installed, origin: parsed.success ? 'cached' : 'bundled', refreshedAt: parsed.success ? cached!.at : null, error: null, dataRoot: this.harness.dataRoot, stateDir: this.harness.stateDir }
87
88
  }
88
89
 
90
+ private receiptState(receipt: HubReceipt): 'installed' | 'changed' | 'incomplete' {
91
+ if (receipt.document && receipt.entry.delivery.kind === 'document') {
92
+ if (receipt.document !== `${receiptName(receipt.entry)}.json`) return 'incomplete'
93
+ try {
94
+ const path = join(this.harness.dataRoot!, 'hub', 'documents', receipt.document)
95
+ const stat = lstatSync(path)
96
+ if (!stat.isFile() || stat.size > HUB_MAX_BYTES) return 'incomplete'
97
+ return digest(readFileSync(path)) === receipt.entry.delivery.sha256 ? 'installed' : 'changed'
98
+ } catch { return 'incomplete' }
99
+ }
100
+ return this.present(receipt) ? 'installed' : 'incomplete'
101
+ }
102
+
89
103
  private present(receipt: HubReceipt): boolean {
90
104
  const root = this.harness.dataRoot
91
105
  if (!root) return false
@@ -169,6 +183,28 @@ export class Hub {
169
183
  return { ...entry, delivery: { ...entry.delivery, bundled: this.decode(bytes) } }
170
184
  }
171
185
 
186
+ private async verifiedPayload(entry: HubEntry): Promise<Buffer> {
187
+ if (entry.delivery.kind === 'pi-package') throw invalid('Packages do not have a JSON payload preview.')
188
+ const key = `${entry.id}@${entry.version}`
189
+ if (this.cachedPayload?.key === key && this.cachedPayload.expiresAt > Date.now()) return this.cachedPayload.bytes
190
+ const bytes = entry.delivery.bundled === undefined ? await this.bytes(entry.delivery.url) : Buffer.from(entry.delivery.bundled)
191
+ if (digest(bytes) !== entry.delivery.sha256) throw invalid('The downloaded item differs from the reviewed version. Nothing was installed.')
192
+ this.decode(bytes)
193
+ this.cachedPayload = { key, bytes, expiresAt: Date.now() + 5 * 60 * 1000 }
194
+ return bytes
195
+ }
196
+
197
+ async preview(id: string, version: string, offset = 0) {
198
+ if (!Number.isSafeInteger(offset) || offset < 0) throw invalid('Preview offset must be a non-negative byte position.')
199
+ const entry = this.find(id, version)
200
+ const bytes = await this.verifiedPayload(entry)
201
+ if (offset > bytes.length || offset < bytes.length && (bytes[offset] & 0xc0) === 0x80) throw invalid('Preview offset is outside the payload or splits a UTF-8 character.')
202
+ let end = Math.min(offset + HUB_PREVIEW_PAGE_BYTES, bytes.length)
203
+ while (end > offset && end < bytes.length && (bytes[end] & 0xc0) === 0x80) end -= 1
204
+ const content = this.decode(bytes.subarray(offset, end))
205
+ return { entry, content, offset, nextOffset: end, totalBytes: bytes.length, done: end >= bytes.length }
206
+ }
207
+
172
208
  async install(id: string, version: string): Promise<HubReceipt> {
173
209
  const root = this.harness.env.root
174
210
  const entry = hubEntrySchema.parse(this.find(id, version))
@@ -181,6 +217,7 @@ export class Hub {
181
217
  const records = this.receipts()
182
218
  const existing = records.find((receipt) => receipt.entry.id === id && receipt.entry.version === version)
183
219
  if (existing && this.present(existing)) return existing
220
+ if (existing) throw invalid('This Hub receipt is changed or incomplete. Uninstall it before installing this version again.')
184
221
  const remaining = records.filter((record) => record !== existing)
185
222
  const receipt: HubReceipt = { entry, installedAt: now(), resourceIds: [], document: null }
186
223
  const choicesBefore = attachments.load(root)
@@ -195,13 +232,14 @@ export class Hub {
195
232
  if (!receipt.resourceIds.length) throw invalid('The package did not add an installable plugin. No hub installation was recorded.')
196
233
  } else {
197
234
  const delivery = entry.delivery
198
- const bytes = delivery.bundled === undefined ? await this.bytes(delivery.url) : Buffer.from(delivery.bundled)
199
- if (digest(bytes) !== delivery.sha256) throw invalid('The downloaded item differs from the reviewed version. Nothing was installed.')
235
+ const bytes = await this.verifiedPayload(entry)
200
236
  const text = this.decode(bytes)
201
237
  if (delivery.kind === 'document') {
202
238
  JSON.parse(text)
203
239
  receipt.document = `${receiptName(entry)}.json`
204
- atomicWrite(join(root, 'hub', 'documents', receipt.document), bytes)
240
+ const documentPath = join(root, 'hub', 'documents', receipt.document)
241
+ try { lstatSync(documentPath); throw invalid('A file already exists at the Hub document path. Review or remove it before installing.') } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
242
+ atomicWrite(documentPath, bytes)
205
243
  } else {
206
244
  const payload = JSON.parse(text)
207
245
  const previous = new Set(registry.list(root).map((item) => item.id))
@@ -232,6 +270,84 @@ export class Hub {
232
270
  } finally { this.installing = false; releaseData?.(); releaseState() }
233
271
  }
234
272
 
273
+ async uninstall(id: string, version: string): Promise<HubReceipt> {
274
+ const root = this.harness.env.root
275
+ const releaseState = acquire(join(this.harness.stateDir, 'hub', 'install.lock'))
276
+ this.installing = true
277
+ let releaseData: (() => void) | null = null
278
+ try {
279
+ releaseData = acquire(join(root, 'hub', 'install.lock'))
280
+ const records = this.receipts()
281
+ const receipt = records.find((item) => item.entry.id === id && item.entry.version === version)
282
+ if (!receipt) throw invalid('This Hub installation receipt no longer exists.')
283
+ const staged = mkdtempSync(join(root, 'hub', '.uninstall-'))
284
+ const moves: Array<{ source: string; staged: string }> = []
285
+ let choicesBefore: ReturnType<typeof attachments.load> | null = null
286
+ let committed = false
287
+ let rollbackComplete = true
288
+ try {
289
+ const ownedPaths: string[] = []
290
+ if (receipt.document) {
291
+ if (receipt.entry.delivery.kind !== 'document' || receipt.document !== `${receiptName(receipt.entry)}.json`) throw invalid('The Hub receipt has an invalid document path.')
292
+ const path = join(root, 'hub', 'documents', receipt.document)
293
+ try {
294
+ const stat = lstatSync(path)
295
+ if (!stat.isFile()) throw invalid('The receipt-owned document path is not a regular file; it was left untouched.')
296
+ ownedPaths.push(path)
297
+ } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
298
+ }
299
+ if (receipt.entry.delivery.kind === 'registry') {
300
+ const expectedKind = receipt.entry.delivery.registryKind
301
+ for (const id of receipt.resourceIds) {
302
+ if (!/^(skills|prompts|rules)\/[a-z0-9-]{1,64}$/.test(id)) throw invalid('The Hub receipt contains an invalid registry resource path.')
303
+ const [kind, name] = id.split('/')
304
+ if (kind !== expectedKind) throw invalid('The Hub receipt contains a resource outside its registry kind.')
305
+ const path = join(root, 'registry', kind, kind === 'skills' ? name : `${name}.md`)
306
+ try { lstatSync(path); ownedPaths.push(path) } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
307
+ }
308
+ choicesBefore = attachments.load(root)
309
+ const choices = structuredClone(choicesBefore)
310
+ const owned = new Set(receipt.resourceIds)
311
+ for (const scope of [choices.global, ...Object.values(choices.agents), ...Object.values(choices.projects)]) {
312
+ scope.on = scope.on.filter((value) => !owned.has(value))
313
+ scope.off = scope.off.filter((value) => !owned.has(value))
314
+ }
315
+ for (let index = 0; index < ownedPaths.length; index += 1) {
316
+ const source = ownedPaths[index]
317
+ const destination = join(staged, String(index))
318
+ renameSync(source, destination)
319
+ moves.push({ source, staged: destination })
320
+ }
321
+ attachments.save(root, choices)
322
+ } else {
323
+ for (let index = 0; index < ownedPaths.length; index += 1) {
324
+ const source = ownedPaths[index]
325
+ const destination = join(staged, String(index))
326
+ renameSync(source, destination)
327
+ moves.push({ source, staged: destination })
328
+ }
329
+ }
330
+ if (receipt.entry.delivery.kind === 'pi-package') for (const pluginId of receipt.resourceIds) await packages.remove(this.harness, { pluginId })
331
+ writeJson(join(root, 'hub', 'installed.json'), records.filter((item) => item !== receipt))
332
+ committed = true
333
+ try { rmSync(staged, { recursive: true, force: true }) } catch { /* The receipt is removed; leave the staged copy for disk recovery. */ }
334
+ this.cachedPayload = this.cachedPayload?.key === `${id}@${version}` ? null : this.cachedPayload
335
+ this.harness.bus.emit({ type: 'hub.changed' })
336
+ this.harness.bus.emit({ type: 'plugins.changed' })
337
+ return receipt
338
+ } catch (error) {
339
+ if (!committed) {
340
+ if (choicesBefore) try { attachments.save(root, choicesBefore) } catch { /* Preserve the original failure and restore the owned files below. */ }
341
+ for (const move of moves.reverse()) {
342
+ try { lstatSync(move.staged); renameSync(move.staged, move.source) } catch { rollbackComplete = false }
343
+ }
344
+ if (rollbackComplete) try { rmSync(staged, { recursive: true, force: true }) } catch { /* Keep the staging directory for recovery. */ }
345
+ }
346
+ throw error
347
+ }
348
+ } finally { this.installing = false; releaseData?.(); releaseState() }
349
+ }
350
+
235
351
  readDocument(id: string, version: string): { entry: HubEntry; content: string } {
236
352
  const receipt = this.receipts().find((item) => item.entry.id === id && item.entry.version === version)
237
353
  if (!receipt?.document || receipt.document !== `${receiptName(receipt.entry)}.json`) throw invalid('Install this document before opening it.')
package/src/hub/schema.ts CHANGED
@@ -2,6 +2,7 @@ import { z } from 'zod'
2
2
 
3
3
  export const HUB_CATALOG_URL = 'https://raw.githubusercontent.com/tanntnny/xingular-hub/main/catalog.json'
4
4
  export const HUB_MAX_BYTES = 16 * 1024 * 1024
5
+ export const HUB_PREVIEW_PAGE_BYTES = 16 * 1024
5
6
 
6
7
  const httpsUrl = z.string().max(2000).url().refine((value) => {
7
8
  const url = new URL(value)
@@ -53,7 +54,17 @@ export const hubReceiptSchema = z.object({
53
54
  entry: hubEntrySchema,
54
55
  installedAt: z.string(),
55
56
  resourceIds: z.array(z.string()),
56
- document: z.string().nullable()
57
+ document: z.string().nullable(),
58
+ state: z.enum(['installed', 'changed', 'incomplete']).optional()
59
+ }).strict()
60
+
61
+ export const hubPreviewPageSchema = z.object({
62
+ entry: hubEntrySchema,
63
+ content: z.string(),
64
+ offset: z.number().int().nonnegative(),
65
+ nextOffset: z.number().int().nonnegative(),
66
+ totalBytes: z.number().int().nonnegative(),
67
+ done: z.boolean()
57
68
  }).strict()
58
69
 
59
70
  export const hubOverviewSchema = z.object({
@@ -69,6 +80,7 @@ export const hubOverviewSchema = z.object({
69
80
  export type HubEntry = z.infer<typeof hubEntrySchema>
70
81
  export type HubCatalog = z.infer<typeof hubCatalogSchema>
71
82
  export type HubReceipt = z.infer<typeof hubReceiptSchema>
83
+ export type HubPreviewPage = z.infer<typeof hubPreviewPageSchema>
72
84
  export type HubOverview = z.infer<typeof hubOverviewSchema>
73
85
 
74
86
  export function meetsMinimum(actual: string, minimum: string): boolean {
@@ -83,6 +83,8 @@ export function methods(harness: Harness): Record<string, Handler> {
83
83
  'hub.list': (params: Params) => harness.hub.list(params.refresh === true),
84
84
  'hub.get': (params: Params) => harness.hub.get(text(params, 'id'), text(params, 'version'), params.preview === true),
85
85
  'hub.install': (params: Params) => harness.hub.install(text(params, 'id'), text(params, 'version')),
86
+ 'hub.preview': (params: Params) => harness.hub.preview(text(params, 'id'), text(params, 'version'), typeof params.offset === 'number' ? params.offset : 0),
87
+ 'hub.uninstall': (params: Params) => harness.hub.uninstall(text(params, 'id'), text(params, 'version')),
86
88
  'hub.readDocument': (params: Params) => harness.hub.readDocument(text(params, 'id'), text(params, 'version')),
87
89
 
88
90
  // ---- Accounts --------------------------------------------------------------------------------
@@ -0,0 +1,40 @@
1
+ import { mkdtempSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { describe, expect, it } from 'vitest'
5
+
6
+ import { CONTEXT_MESSAGE_TYPE, CONTEXT_SNAPSHOT_KEY } from '../prompt/compile'
7
+ import { PLAN_INSTRUCTIONS, PLAN_SECTION_ID, planModeTools, planRefusal, planSection } from './planMode'
8
+
9
+ function history(sectionIds: string[]): string {
10
+ const file = join(mkdtempSync(join(tmpdir(), 'plan-history-')), 'session.jsonl')
11
+ const snapshot = sectionIds.map((id) => ({ id, title: id, body: 'x', sources: [], budget: 100, size: 1, cut: false }))
12
+ writeFileSync(file, [
13
+ JSON.stringify({ type: 'session', id: 'session' }),
14
+ JSON.stringify({ type: 'custom_message', id: 'c1', parentId: null, customType: CONTEXT_MESSAGE_TYPE, details: { [CONTEXT_SNAPSHOT_KEY]: snapshot } })
15
+ ].join('\n'))
16
+ return file
17
+ }
18
+
19
+ describe('plan mode', () => {
20
+ it('keeps read-only inspection, questions and recall, plus provider tools marked read-only', () => {
21
+ const normal = ['read', 'bash', 'edit', 'write', 'grep', 'ask_user', 'wiki_search', 'memory_append', 'spawn_subagent', 'files_read', 'files_write', 'skill_load']
22
+ expect(planModeTools(normal, ['files_read'])).toEqual(['read', 'grep', 'ask_user', 'wiki_search', 'files_read', 'skill_load'])
23
+ })
24
+
25
+ it('refuses changes, commands, memory writes and delegation with a reason the model can act on', () => {
26
+ const readOnly = new Set(['files_read'])
27
+ for (const tool of ['read', 'grep', 'wiki_read', 'files_read', 'ask_user']) expect(planRefusal(tool, readOnly), tool).toBeNull()
28
+ for (const tool of ['bash', 'write', 'edit', 'memory_append', 'wiki_propose', 'spawn_subagent', 'mcp_call_tool', 'files_write']) {
29
+ expect(planRefusal(tool, readOnly), tool).toContain('Plan mode is read-only')
30
+ }
31
+ })
32
+
33
+ it('adds its instructions while on, withdraws them once after, and is absent otherwise', () => {
34
+ expect(planSection(true, null)).toMatchObject({ id: PLAN_SECTION_ID, body: PLAN_INSTRUCTIONS, dynamic: true })
35
+ expect(PLAN_INSTRUCTIONS).toContain('<proposed_plan>')
36
+ expect(planSection(false, null)).toBeNull()
37
+ expect(planSection(false, history(['blocks']))).toBeNull()
38
+ expect(planSection(false, history(['blocks', PLAN_SECTION_ID]))).toMatchObject({ id: PLAN_SECTION_ID, body: null, dynamic: true })
39
+ })
40
+ })