@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.
- package/CHANGELOG.md +10 -0
- package/README.md +7 -3
- package/dist/cli.js +8709 -8145
- package/dist/terminal-pi.js +79 -18
- package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +3 -1
- package/docs/terminal-tui.md +19 -13
- package/package.json +1 -1
- package/src/hub/hub.test.ts +49 -1
- package/src/hub/hub.ts +122 -6
- package/src/hub/schema.ts +13 -1
- package/src/server/methods.ts +2 -0
- package/src/sessions/planMode.test.ts +40 -0
- package/src/sessions/planMode.ts +57 -0
- package/src/sessions/sessions.test.ts +23 -0
- package/src/sessions/sessions.ts +17 -1
- package/src/terminal/piExtension.lifecycle.test.ts +28 -1
- package/src/terminal/piExtension.ts +15 -1
- package/src/terminal/tui/application.test.ts +31 -14
- package/src/terminal/tui/application.ts +75 -21
- package/src/terminal/tui/commands.test.ts +13 -0
- package/src/terminal/tui/commands.ts +7 -0
- package/src/terminal/tui/editor.ts +16 -1
- package/src/terminal/tui/keybindings.test.ts +1 -2
- package/src/terminal/tui/keybindings.ts +1 -2
- package/src/terminal/tui/planMode.ts +2 -12
- package/src/terminal/tui/runtime.ts +24 -2
- package/src/tools/types.ts +2 -0
package/dist/terminal-pi.js
CHANGED
|
@@ -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,
|
|
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, ...
|
|
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
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
|
282
|
-
if (!
|
|
283
|
-
if (
|
|
284
|
-
if (
|
|
285
|
-
await answerPrompt(ui, call, loginId,
|
|
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 = [...
|
|
289
|
-
if (step &&
|
|
290
|
-
shownRevision =
|
|
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,
|
|
310
|
-
if (
|
|
311
|
-
else if (
|
|
312
|
-
else ui.notify(
|
|
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
|
|
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.
|
package/docs/terminal-tui.md
CHANGED
|
@@ -87,16 +87,22 @@ The decision and its consequences are recorded in
|
|
|
87
87
|
|
|
88
88
|
## Plan mode and thinking keys
|
|
89
89
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
306
|
-
|
|
307
|
-
integration
|
|
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.
|
|
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": {
|
package/src/hub/hub.test.ts
CHANGED
|
@@ -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).
|
|
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().
|
|
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 =
|
|
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
|
-
|
|
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 {
|
package/src/server/methods.ts
CHANGED
|
@@ -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
|
+
})
|