@xingular/harness 0.1.0 → 0.1.2
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/dist/cli.js +799 -335
- package/dist/terminal-pi.js +2 -2
- package/package.json +4 -4
- package/src/accounts/env.test.ts +95 -0
- package/src/accounts/env.ts +176 -42
- package/src/capabilities/api.ts +3 -3
- package/src/harness.ts +12 -2
- package/src/index.ts +2 -1
- package/src/mcp/connections.ts +176 -3
- package/src/mcp/scopeMigration.ts +204 -0
- package/src/mcp/service.ts +18 -36
- package/src/mcp/types.ts +3 -0
- package/src/memory/api.ts +5 -3
- package/src/memory/dispatch.ts +2 -2
- package/src/memory/maintenance.test.ts +1 -1
- package/src/plugins/attachments.ts +13 -0
- package/src/plugins/catalogue.ts +3 -2
- package/src/plugins/manifest.ts +34 -2
- package/src/plugins/plugins.ts +8 -3
- package/src/plugins/types.ts +19 -0
- package/src/prompt/compile.ts +4 -4
- package/src/server/methods.ts +6 -0
- package/src/sessions/sessions.ts +10 -10
- package/src/skills/registry.ts +16 -3
- package/src/terminal/piExtension.ts +4 -2
- package/src/tools/memoryTools.ts +2 -2
package/dist/terminal-pi.js
CHANGED
|
@@ -202,7 +202,7 @@ async function piExtension_default(pi) {
|
|
|
202
202
|
if (command?.type === "interrupt") context?.abort();
|
|
203
203
|
else if (command?.type === "send") {
|
|
204
204
|
if (context?.ui.getEditorText().trim()) throw new Error("The terminal contains a user draft.");
|
|
205
|
-
pi.sendUserMessage(command.text);
|
|
205
|
+
pi.sendUserMessage(command.text, context?.isIdle() === false ? { deliverAs: "followUp" } : void 0);
|
|
206
206
|
}
|
|
207
207
|
}).catch(() => {
|
|
208
208
|
context?.abort();
|
|
@@ -218,7 +218,7 @@ async function piExtension_default(pi) {
|
|
|
218
218
|
usage = emptyUsage();
|
|
219
219
|
reasoningTokens = void 0;
|
|
220
220
|
costReported = false;
|
|
221
|
-
await terminalCall("begin", { text: event.prompt });
|
|
221
|
+
await terminalCall("begin", { text: event.messageType === "user" ? event.prompt : "" });
|
|
222
222
|
});
|
|
223
223
|
pi.on("message_end", async (event) => {
|
|
224
224
|
if (event.message.role !== "assistant") return;
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xingular/harness",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Xingular Harness
|
|
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
|
"exports": {
|
|
7
7
|
".": "./src/index.ts",
|
|
8
8
|
"./*": "./src/*"
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"prove": "npm run build && node scripts/prove-standalone.mjs"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@earendil-works/pi-coding-agent": "1.
|
|
21
|
+
"@earendil-works/pi-coding-agent": "1.2.0",
|
|
22
22
|
"@modelcontextprotocol/client": "^2.2.0",
|
|
23
23
|
"@modelcontextprotocol/server": "^2.2.0",
|
|
24
24
|
"typebox": "^1.3.7",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"vitest": "^3.2.4"
|
|
33
33
|
},
|
|
34
34
|
"engines": {
|
|
35
|
-
"node": ">=22.
|
|
35
|
+
"node": ">=22.19"
|
|
36
36
|
},
|
|
37
37
|
"files": [
|
|
38
38
|
"src",
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { existsSync, lstatSync, linkSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, symlinkSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { expect, test } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import { configDir, ensureSharedDir, linkSharedEntries, sharedDir, type EntryLinker } from './env'
|
|
7
|
+
|
|
8
|
+
const temp = () => mkdtempSync(join(tmpdir(), 'harness-env-'))
|
|
9
|
+
const refused = (code = 'EPERM') => Object.assign(new Error(`${code}: operation not permitted, symlink`), { code })
|
|
10
|
+
const backups = (folder: string) => readdirSync(folder).filter((name) => name.includes('agentx-backup'))
|
|
11
|
+
|
|
12
|
+
// A Windows account without the symlink privilege: file symlinks are refused, junctions and hard links work.
|
|
13
|
+
const windowsLike: EntryLinker = {
|
|
14
|
+
symlink: (target, path, isDir) => {
|
|
15
|
+
if (!isDir) throw refused()
|
|
16
|
+
symlinkSync(target, path)
|
|
17
|
+
},
|
|
18
|
+
hardLink: (target, path) => linkSync(target, path)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const refusesAll: EntryLinker = {
|
|
22
|
+
symlink: () => {
|
|
23
|
+
throw refused()
|
|
24
|
+
},
|
|
25
|
+
hardLink: () => {
|
|
26
|
+
throw refused()
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test('without the symlink privilege, files are hard-linked and folders are junctioned', () => {
|
|
31
|
+
const dir = temp()
|
|
32
|
+
const home = configDir(dir, { id: 'acc1' })
|
|
33
|
+
mkdirSync(home, { recursive: true })
|
|
34
|
+
writeFileSync(join(home, 'settings.json'), '{"theme":"dark"}')
|
|
35
|
+
|
|
36
|
+
const first = linkSharedEntries(dir, { id: 'acc1' }, windowsLike)
|
|
37
|
+
const shared = join(sharedDir(dir), 'settings.json')
|
|
38
|
+
expect(first.entries.find((entry) => entry.name === 'settings.json')).toEqual({ name: 'settings.json', state: 'linked' })
|
|
39
|
+
expect(lstatSync(join(home, 'settings.json')).ino).toBe(lstatSync(shared).ino)
|
|
40
|
+
expect(lstatSync(join(home, 'settings.json')).isSymbolicLink()).toBe(false)
|
|
41
|
+
expect(lstatSync(join(home, 'skills')).isSymbolicLink()).toBe(true)
|
|
42
|
+
|
|
43
|
+
// Entries with no shared copy yet cannot be hard-linked, so they stay the account's own.
|
|
44
|
+
expect(first.entries.find((entry) => entry.name === 'AGENTS.md')).toMatchObject({ state: 'unshared' })
|
|
45
|
+
|
|
46
|
+
// A second launch finds the links in place and moves nothing aside.
|
|
47
|
+
const second = linkSharedEntries(dir, { id: 'acc1' }, windowsLike)
|
|
48
|
+
expect(second.entries.find((entry) => entry.name === 'settings.json')?.state).toBe('linked')
|
|
49
|
+
expect(second.entries.find((entry) => entry.name === 'skills')?.state).toBe('linked')
|
|
50
|
+
expect(backups(home)).toEqual([])
|
|
51
|
+
expect(backups(sharedDir(dir))).toEqual([])
|
|
52
|
+
expect(readFileSync(join(home, 'settings.json'), 'utf8')).toBe('{"theme":"dark"}')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('when links are refused, the account keeps its own settings', () => {
|
|
56
|
+
const dir = temp()
|
|
57
|
+
const home = configDir(dir, { id: 'acc1' })
|
|
58
|
+
mkdirSync(home, { recursive: true })
|
|
59
|
+
writeFileSync(join(home, 'settings.json'), '{"mine":true}')
|
|
60
|
+
|
|
61
|
+
const { entries } = linkSharedEntries(dir, { id: 'acc1' }, refusesAll)
|
|
62
|
+
expect(entries.find((entry) => entry.name === 'settings.json')).toEqual({ name: 'settings.json', state: 'unshared', reason: 'EPERM' })
|
|
63
|
+
expect(readFileSync(join(home, 'settings.json'), 'utf8')).toBe('{"mine":true}')
|
|
64
|
+
expect(existsSync(join(sharedDir(dir), 'settings.json'))).toBe(false)
|
|
65
|
+
expect(backups(home)).toEqual([])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('when links are refused, a shared file the account lacks is copied in and stays put', () => {
|
|
69
|
+
const dir = temp()
|
|
70
|
+
writeFileSync(join(ensureSharedDir(dir), 'settings.json'), '{"shared":true}')
|
|
71
|
+
const home = configDir(dir, { id: 'acc2' })
|
|
72
|
+
|
|
73
|
+
const first = linkSharedEntries(dir, { id: 'acc2' }, refusesAll)
|
|
74
|
+
expect(readFileSync(join(home, 'settings.json'), 'utf8')).toBe('{"shared":true}')
|
|
75
|
+
expect(first.entries.find((entry) => entry.name === 'settings.json')?.state).toBe('unshared')
|
|
76
|
+
|
|
77
|
+
const second = linkSharedEntries(dir, { id: 'acc2' }, refusesAll)
|
|
78
|
+
expect(second.entries.find((entry) => entry.name === 'settings.json')?.state).toBe('unshared')
|
|
79
|
+
expect(readFileSync(join(home, 'settings.json'), 'utf8')).toBe('{"shared":true}')
|
|
80
|
+
expect(backups(home)).toEqual([])
|
|
81
|
+
expect(backups(sharedDir(dir))).toEqual([])
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('a refused link leaves the account\'s existing link as it was', () => {
|
|
85
|
+
const dir = temp()
|
|
86
|
+
const home = configDir(dir, { id: 'acc3' })
|
|
87
|
+
mkdirSync(home, { recursive: true })
|
|
88
|
+
const elsewhere = join(temp(), 'skills')
|
|
89
|
+
mkdirSync(elsewhere)
|
|
90
|
+
symlinkSync(elsewhere, join(home, 'skills'))
|
|
91
|
+
|
|
92
|
+
const { entries } = linkSharedEntries(dir, { id: 'acc3' }, refusesAll)
|
|
93
|
+
expect(entries.find((entry) => entry.name === 'skills')?.state).toBe('unshared')
|
|
94
|
+
expect(readlinkSync(join(home, 'skills'))).toBe(elsewhere)
|
|
95
|
+
})
|
package/src/accounts/env.ts
CHANGED
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
// variables are removed. A launch that would fall back to `~/.pi` is refused.
|
|
4
4
|
//
|
|
5
5
|
// Settings, skills, prompts, and instructions are shared by every account: the account's agent
|
|
6
|
-
// folder links those entries to `_shared/pi/`.
|
|
7
|
-
// account's own
|
|
8
|
-
|
|
6
|
+
// folder links those entries to `_shared/pi/`. Where the system refuses a link (Windows without
|
|
7
|
+
// the symlink privilege), the entry stays the account's own, and a shared file is copied in so
|
|
8
|
+
// its settings still load. Credentials, sessions, and history stay in the account's own folder.
|
|
9
|
+
import { chmodSync, copyFileSync, linkSync, lstatSync, mkdirSync, readlinkSync, renameSync, rmdirSync, statSync, symlinkSync, unlinkSync, utimesSync } from 'node:fs'
|
|
9
10
|
import { homedir } from 'node:os'
|
|
10
|
-
import { basename, dirname, join, sep } from 'node:path'
|
|
11
|
+
import { basename, dirname, join, resolve, sep } from 'node:path'
|
|
11
12
|
|
|
12
13
|
import { HarnessError } from '../core/errors'
|
|
13
14
|
import type { Account } from './store'
|
|
@@ -104,53 +105,178 @@ const modified = (path: string): number => {
|
|
|
104
105
|
}
|
|
105
106
|
}
|
|
106
107
|
|
|
107
|
-
/** Moves a real file or folder aside so the shared link can take its place. */
|
|
108
|
-
function backUp(path: string):
|
|
109
|
-
|
|
108
|
+
/** Moves a real file or folder aside so the shared link can take its place; returns where it went. */
|
|
109
|
+
function backUp(path: string): string {
|
|
110
|
+
const backup = join(dirname(path), `${basename(path)}.agentx-backup-${Math.floor(Date.now() / 1000)}`)
|
|
111
|
+
renameSync(path, backup)
|
|
112
|
+
return backup
|
|
110
113
|
}
|
|
111
114
|
|
|
112
115
|
/**
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* when there is none or it is newer; otherwise it is kept as a backup.
|
|
116
|
+
* How one shared entry is linked into an account folder. Windows refuses a symbolic link to a
|
|
117
|
+
* user without the symlink privilege, so each kind has a fallback that needs none.
|
|
116
118
|
*/
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
119
|
+
export interface EntryLinker {
|
|
120
|
+
/** A symbolic link to a file, or a junction to a folder (junctions need no privilege on Windows). */
|
|
121
|
+
symlink(target: string, path: string, isDir: boolean): void
|
|
122
|
+
/** A hard link to a file on the same volume. */
|
|
123
|
+
hardLink(target: string, path: string): void
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const nodeLinker: EntryLinker = {
|
|
127
|
+
symlink: (target, path, isDir) => symlinkSync(resolve(target), path, isDir ? 'junction' : 'file'),
|
|
128
|
+
hardLink: (target, path) => linkSync(target, path)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const codeOf = (error: unknown): string => (error as NodeJS.ErrnoException).code ?? String(error)
|
|
132
|
+
const PRIVILEGE_CODES = ['EPERM', 'EACCES']
|
|
133
|
+
|
|
134
|
+
function createLink(target: string, path: string, isDir: boolean, linker: EntryLinker): void {
|
|
135
|
+
try {
|
|
136
|
+
linker.symlink(target, path, isDir)
|
|
137
|
+
} catch (error) {
|
|
138
|
+
// A file can still be hard-linked without the privilege; a folder has no such fallback.
|
|
139
|
+
if (isDir || !PRIVILEGE_CODES.includes(codeOf(error))) throw error
|
|
140
|
+
linker.hardLink(target, path)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const isLink = (path: string): boolean => {
|
|
145
|
+
try {
|
|
146
|
+
return lstatSync(path).isSymbolicLink()
|
|
147
|
+
} catch {
|
|
148
|
+
return false
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Windows reports some link targets with the `\\?\` prefix.
|
|
153
|
+
const LONG_PREFIX = /^\\\\\?\\/
|
|
154
|
+
function samePath(a: string, b: string): boolean {
|
|
155
|
+
const normal = (path: string): string => {
|
|
156
|
+
const full = resolve(path.replace(LONG_PREFIX, ''))
|
|
157
|
+
return process.platform === 'win32' ? full.toLowerCase() : full
|
|
158
|
+
}
|
|
159
|
+
return normal(a) === normal(b)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** A hard link is the same file on disk as its target, so it has the same device and inode. */
|
|
163
|
+
function sameFile(a: string, b: string): boolean {
|
|
164
|
+
try {
|
|
165
|
+
const left = lstatSync(a)
|
|
166
|
+
const right = lstatSync(b)
|
|
167
|
+
return left.ino !== 0 && left.dev === right.dev && left.ino === right.ino
|
|
168
|
+
} catch {
|
|
169
|
+
return false
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function isLinkTo(path: string, target: string, isDir: boolean): boolean {
|
|
174
|
+
try {
|
|
175
|
+
const meta = lstatSync(path)
|
|
176
|
+
if (meta.isSymbolicLink()) return samePath(readlinkSync(path), target)
|
|
177
|
+
return !isDir && meta.isFile() && sameFile(path, target)
|
|
178
|
+
} catch {
|
|
179
|
+
return false
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Removes a link without touching what it points to. A Windows junction needs `rmdir`. */
|
|
184
|
+
function removeLink(path: string, isDir: boolean): void {
|
|
185
|
+
try {
|
|
186
|
+
unlinkSync(path)
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (!isDir) throw error
|
|
189
|
+
rmdirSync(path)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* A file the account does not have, for an entry whose link was refused: a copy of the shared one
|
|
195
|
+
* (with its time kept, so the newest-wins rule does not move it), so the account's settings load.
|
|
196
|
+
*/
|
|
197
|
+
function keepCopy(target: string, path: string, isDir: boolean): void {
|
|
198
|
+
if (isDir || present(path) || !present(target)) return
|
|
199
|
+
try {
|
|
200
|
+
copyFileSync(target, path)
|
|
201
|
+
const { atime, mtime } = statSync(target)
|
|
202
|
+
utimesSync(path, atime, mtime)
|
|
203
|
+
} catch {
|
|
204
|
+
// The account simply keeps no copy; its sign-in and its own files are not affected.
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** What happened to one shared entry: linked to the shared copy, or left as the account's own. */
|
|
209
|
+
export interface SharedEntry {
|
|
210
|
+
name: string
|
|
211
|
+
state: 'linked' | 'unshared'
|
|
212
|
+
/** Why the link was refused, when it was. */
|
|
213
|
+
reason?: string
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Links one entry to its shared copy. A real entry found in the account folder (from before
|
|
218
|
+
* sharing, or a tool that replaced the link while saving) becomes the shared copy when there is
|
|
219
|
+
* none or it is newer; otherwise it is kept as a backup. If the link is then refused, every move
|
|
220
|
+
* is undone, so the account keeps what it had.
|
|
221
|
+
*/
|
|
222
|
+
function linkOne(name: string, target: string, path: string, isDir: boolean, linker: EntryLinker): SharedEntry {
|
|
223
|
+
if (isLinkTo(path, target, isDir)) {
|
|
224
|
+
if (isDir) mkdirSync(target, { recursive: true })
|
|
225
|
+
return { name, state: 'linked' }
|
|
226
|
+
}
|
|
227
|
+
const undo: (() => void)[] = []
|
|
228
|
+
try {
|
|
229
|
+
if (isLink(path)) {
|
|
230
|
+
const previous = readlinkSync(path)
|
|
231
|
+
removeLink(path, isDir)
|
|
232
|
+
undo.push(() => nodeLinker.symlink(previous, path, isDir))
|
|
233
|
+
} else if (present(path)) {
|
|
234
|
+
if (!present(target)) {
|
|
235
|
+
renameSync(path, target)
|
|
236
|
+
undo.push(() => renameSync(target, path))
|
|
237
|
+
} else if (!isDir && modified(path) > modified(target)) {
|
|
238
|
+
const backup = backUp(target)
|
|
239
|
+
undo.push(() => renameSync(backup, target))
|
|
240
|
+
renameSync(path, target)
|
|
241
|
+
undo.push(() => renameSync(target, path))
|
|
242
|
+
} else {
|
|
243
|
+
const backup = backUp(path)
|
|
244
|
+
undo.push(() => renameSync(backup, path))
|
|
132
245
|
}
|
|
133
|
-
unlinkSync(local)
|
|
134
|
-
} else if (meta) {
|
|
135
|
-
if (!present(target)) renameSync(local, target)
|
|
136
|
-
else if (!isDir && modified(local) > modified(target)) {
|
|
137
|
-
backUp(target)
|
|
138
|
-
renameSync(local, target)
|
|
139
|
-
} else backUp(local)
|
|
140
246
|
}
|
|
141
247
|
if (isDir) mkdirSync(target, { recursive: true })
|
|
142
|
-
|
|
248
|
+
createLink(target, path, isDir, linker)
|
|
249
|
+
return { name, state: 'linked' }
|
|
250
|
+
} catch (error) {
|
|
251
|
+
for (const step of undo.reverse()) {
|
|
252
|
+
try {
|
|
253
|
+
step()
|
|
254
|
+
} catch {
|
|
255
|
+
// The entry stays where that step left it; nothing further can be restored safely.
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
keepCopy(target, path, isDir)
|
|
259
|
+
return { name, state: 'unshared', reason: codeOf(error) }
|
|
143
260
|
}
|
|
144
261
|
}
|
|
145
262
|
|
|
146
|
-
/**
|
|
147
|
-
|
|
263
|
+
/**
|
|
264
|
+
* Creates the account's runtime folder and links the shared entries into it. Never throws for a
|
|
265
|
+
* refused link: each entry is reported, and the account falls back to its own copy.
|
|
266
|
+
*/
|
|
267
|
+
export function linkSharedEntries(accountsDir: string, account: Pick<Account, 'id'>, linker: EntryLinker = nodeLinker): { dir: string; entries: SharedEntry[] } {
|
|
148
268
|
makePrivate(join(accountsDir, account.id))
|
|
149
269
|
makePrivate(homeDir(accountsDir, account.id))
|
|
150
270
|
const dir = configDir(accountsDir, account)
|
|
151
271
|
makePrivate(dir)
|
|
152
|
-
|
|
153
|
-
|
|
272
|
+
const shared = ensureSharedDir(accountsDir)
|
|
273
|
+
const entries = SHARED_ENTRIES.map(([name, isDir]) => linkOne(name, join(shared, name), join(dir, name), isDir, linker))
|
|
274
|
+
return { dir, entries }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Creates the account's runtime folder and links the shared entries into it. */
|
|
278
|
+
export function ensureHome(accountsDir: string, account: Pick<Account, 'id'>): string {
|
|
279
|
+
return linkSharedEntries(accountsDir, account).dir
|
|
154
280
|
}
|
|
155
281
|
|
|
156
282
|
const inside = (path: string, parent: string): boolean => path === parent || path.startsWith(parent.endsWith(sep) ? parent : `${parent}${sep}`)
|
|
@@ -166,16 +292,20 @@ export function assertIsolated(accountsDir: string, dir: string): void {
|
|
|
166
292
|
}
|
|
167
293
|
}
|
|
168
294
|
|
|
169
|
-
|
|
170
|
-
export function launchEnv(accountsDir: string, account: Pick<Account, 'id'>, inherited: NodeJS.ProcessEnv = process.env): Record<string, string | null> {
|
|
171
|
-
const dir = ensureHome(accountsDir, account)
|
|
172
|
-
assertIsolated(accountsDir, dir)
|
|
295
|
+
function sessionEnv(dir: string, inherited: NodeJS.ProcessEnv): Record<string, string | null> {
|
|
173
296
|
const env: Record<string, string | null> = {}
|
|
174
297
|
for (const name of inheritedCredentialVars(inherited)) env[name] = null
|
|
175
298
|
env.PI_CODING_AGENT_DIR = dir
|
|
176
299
|
return env
|
|
177
300
|
}
|
|
178
301
|
|
|
302
|
+
/** Variables to set, or remove (`null`), when launching a session for this account. */
|
|
303
|
+
export function launchEnv(accountsDir: string, account: Pick<Account, 'id'>, inherited: NodeJS.ProcessEnv = process.env): Record<string, string | null> {
|
|
304
|
+
const dir = ensureHome(accountsDir, account)
|
|
305
|
+
assertIsolated(accountsDir, dir)
|
|
306
|
+
return sessionEnv(dir, inherited)
|
|
307
|
+
}
|
|
308
|
+
|
|
179
309
|
/** What a session needs to run for an account. */
|
|
180
310
|
export interface AccountContext {
|
|
181
311
|
id: string
|
|
@@ -185,10 +315,14 @@ export interface AccountContext {
|
|
|
185
315
|
env: Record<string, string | null>
|
|
186
316
|
/** The account's own agent folder. */
|
|
187
317
|
configDir: string
|
|
318
|
+
/** Which shared entries are linked, and which the account keeps as its own. */
|
|
319
|
+
sharing: SharedEntry[]
|
|
188
320
|
}
|
|
189
321
|
|
|
190
322
|
export function accountContext(accountsDir: string, account: Account): AccountContext {
|
|
191
|
-
|
|
323
|
+
const { dir, entries } = linkSharedEntries(accountsDir, account)
|
|
324
|
+
assertIsolated(accountsDir, dir)
|
|
325
|
+
return { id: account.id, provider: 'pi', label: account.label, env: sessionEnv(dir, process.env), configDir: dir, sharing: entries }
|
|
192
326
|
}
|
|
193
327
|
|
|
194
328
|
/** The process environment for a `pi` process launched on behalf of this account. */
|
package/src/capabilities/api.ts
CHANGED
|
@@ -50,11 +50,11 @@ export async function resolve(harness: Harness, params: ListParams & { tools?: r
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
export function toolIntersection(parent: CapabilitySet, assignee: CapabilitySet, taskTools
|
|
54
|
-
const permitted =
|
|
53
|
+
export function toolIntersection(parent: CapabilitySet, assignee: CapabilitySet, taskTools?: readonly string[]): CapabilitySet {
|
|
54
|
+
const permitted = taskTools === undefined ? null : new Set(taskTools)
|
|
55
55
|
return {
|
|
56
56
|
version: 1,
|
|
57
|
-
tools: parent.tools.filter((tool) => assignee.tools.includes(tool) && permitted.has(tool)),
|
|
57
|
+
tools: parent.tools.filter((tool) => assignee.tools.includes(tool) && (!permitted || permitted.has(tool))),
|
|
58
58
|
connectors: parent.connectors.filter((id) => assignee.connectors.includes(id)),
|
|
59
59
|
contributions: parent.contributions.filter((id) => assignee.contributions.includes(id))
|
|
60
60
|
}
|
package/src/harness.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { readJsonOr } from './core/fs'
|
|
|
15
15
|
import { closeIndex } from './core/index'
|
|
16
16
|
import { projectsOrNone } from './core/projects'
|
|
17
17
|
import { ConnectedApps } from './mcp/service'
|
|
18
|
+
import { migrateConnectionScopes } from './mcp/scopeMigration'
|
|
18
19
|
import type { MaintainerFactory } from './memory/maintainer'
|
|
19
20
|
import { Maintenance } from './memory/maintenance'
|
|
20
21
|
import * as migrate from './memory/migrate'
|
|
@@ -179,7 +180,7 @@ export class Harness {
|
|
|
179
180
|
policy: this.policy,
|
|
180
181
|
broker: this.apps,
|
|
181
182
|
plugins: this.plugins,
|
|
182
|
-
resolveCapabilities: (caller, tools) => capabilities.resolve(this, { agentId: caller.agentId ?? null, projectId: caller.projectId ?? null, tools }),
|
|
183
|
+
resolveCapabilities: (caller, tools, cwd) => capabilities.resolve(this, { agentId: caller.agentId ?? null, projectId: caller.projectId ?? null, cwd, tools }),
|
|
183
184
|
factory: options.sessionFactory
|
|
184
185
|
})
|
|
185
186
|
this.maintenance = new Maintenance({ env: this.env, bus: this.bus, accounts: this.accounts, factory: options.maintainerFactory, idleMs: options.idleMs })
|
|
@@ -221,13 +222,22 @@ export class Harness {
|
|
|
221
222
|
void this.hooks.reload()
|
|
222
223
|
for (const folder of ['registry/skills', 'registry/prompts', 'registry/rules', 'config', 'runs']) mkdirSync(join(root, folder), { recursive: true })
|
|
223
224
|
let migrationError: string | null = null
|
|
225
|
+
let wikiMigrationError: string | null = null
|
|
224
226
|
try {
|
|
225
227
|
if (migrate.run(this.env, agentIds)) log(`started Wiki storage version ${migrate.WIKI_VERSION}; the earlier Wiki is kept as a backup in the data root`)
|
|
226
228
|
} catch (error) {
|
|
227
229
|
migrationError = error instanceof Error ? error.message : String(error)
|
|
230
|
+
wikiMigrationError = migrationError
|
|
228
231
|
log('Wiki migration failed', migrationError)
|
|
229
232
|
}
|
|
230
|
-
|
|
233
|
+
try {
|
|
234
|
+
migrateConnectionScopes(root, this.stateDir)
|
|
235
|
+
} catch (error) {
|
|
236
|
+
const detail = error instanceof Error ? error.message : String(error)
|
|
237
|
+
migrationError = migrationError ? `${migrationError}; connector scope migration: ${detail}` : detail
|
|
238
|
+
log('connector scope migration failed', detail)
|
|
239
|
+
}
|
|
240
|
+
if (!wikiMigrationError) {
|
|
231
241
|
try {
|
|
232
242
|
store.ensureLayout(root, this.env.productName)
|
|
233
243
|
// Every project has its fixed pages, and the generated indexes match the files.
|
package/src/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ export { fileSecretStore, memorySecretStore, secretKey, type SecretStore } from
|
|
|
17
17
|
export type { Account, AccountsOverview, Pool } from './accounts/store'
|
|
18
18
|
export type { ModelOption } from './accounts/accounts'
|
|
19
19
|
export type { Capability, CapabilityCatalogue, CapabilityKind, CapabilityScope, CapabilitySet } from './capabilities/types'
|
|
20
|
+
export type { RunCapabilitySnapshot } from './capabilities/runSnapshot'
|
|
20
21
|
|
|
21
22
|
export type { CallerContext } from './memory/caller'
|
|
22
23
|
export { dispatch as memoryCall } from './memory/dispatch'
|
|
@@ -61,7 +62,7 @@ export { HARNESS_PLUGINS } from './plugins/builtin'
|
|
|
61
62
|
export { FIRST_PARTY_PLUGINS } from './plugins/firstParty'
|
|
62
63
|
export type { Found, Resolver } from './plugins/discover'
|
|
63
64
|
export type { Loading, Registered } from './plugins/plugins'
|
|
64
|
-
export { CONTRIBUTION_KINDS, PLUGIN_SOURCES, type Contribution, type ContributionKind, type ContributionState, type Delivery, type Inventory, type InventoryItem, type Plugin, type PluginDescriptor, type PluginManage, type PluginSetting, type PluginSource } from './plugins/types'
|
|
65
|
+
export { CONTRIBUTION_KINDS, PLUGIN_SOURCES, type ConnectorTemplate, type Contribution, type ContributionKind, type ContributionState, type Delivery, type Inventory, type InventoryItem, type Plugin, type PluginDescriptor, type PluginManage, type PluginSetting, type PluginSource } from './plugins/types'
|
|
65
66
|
|
|
66
67
|
export { methods } from './server/methods'
|
|
67
68
|
export { remoteDirectory, remotePolicy, remoteSecrets, remoteTools } from './server/remote'
|