@tshu-w/pi-agents 0.0.0-stage → 0.1.0
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/LICENSE +661 -0
- package/README.md +81 -2
- package/bin/daemon.mjs +204 -0
- package/bin/host.mjs +170 -0
- package/bin/pd.mjs +139 -0
- package/docs/PROGRAM.md +310 -0
- package/docs/SPEC.md +427 -0
- package/docs/WORKBENCH.md +165 -0
- package/package.json +62 -4
- package/src/agents/agents.ts +882 -0
- package/src/agents/registry.ts +257 -0
- package/src/agents/scheduler.ts +204 -0
- package/src/agents/search.ts +131 -0
- package/src/agents/tool.ts +205 -0
- package/src/daemon/client.mjs +84 -0
- package/src/daemon/router.mjs +103 -0
- package/src/daemon/sessions.mjs +129 -0
- package/src/daemon/version.mjs +19 -0
- package/src/host/client.mjs +139 -0
- package/src/host/extension.ts +74 -0
- package/src/host/frames.mjs +34 -0
- package/src/host/keys.mjs +19 -0
- package/src/index.ts +208 -0
- package/src/output.ts +56 -0
- package/src/params.ts +4 -0
- package/src/pi.mjs +16 -0
- package/src/programs/codemode.ts +74 -0
- package/src/programs/execute.ts +131 -0
- package/src/programs/host-patches.ts +128 -0
- package/src/programs/loadout.ts +99 -0
- package/src/programs/programs.ts +284 -0
- package/src/programs/sandbox.ts +90 -0
- package/src/programs/scope.ts +94 -0
- package/src/programs/tool.ts +198 -0
- package/src/render-call.ts +90 -0
- package/src/roots/discovery.mjs +113 -0
- package/src/roots/guard.mjs +227 -0
- package/src/roots/lock-extension.mjs +167 -0
- package/src/roots/locks.mjs +167 -0
- package/src/roots/paths.mjs +66 -0
- package/src/roots/report.ts +156 -0
- package/src/roots/runtime.ts +174 -0
- package/src/roots/snapshot.mjs +46 -0
- package/src/roots/socket-recovery.mjs +21 -0
- package/src/roots/transport.mjs +341 -0
- package/src/ui/panel.ts +96 -0
- package/src/ui/viewer.ts +445 -0
- package/src/workbench.mjs +587 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import { closeSync, openSync, readFileSync, readSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { acquireLock, canonicalPath, SessionOccupiedError } from './locks.mjs';
|
|
4
|
+
import { resolvePath } from './paths.mjs';
|
|
5
|
+
|
|
6
|
+
/** The ID in a Session file's header, its first parseable entry. Reads only up to that entry. */
|
|
7
|
+
export function readSessionId(path) {
|
|
8
|
+
let fd;
|
|
9
|
+
try { fd = openSync(path, 'r'); }
|
|
10
|
+
catch (error) { if (error.code === 'ENOENT') return; throw error; }
|
|
11
|
+
try {
|
|
12
|
+
let rest = Buffer.alloc(0);
|
|
13
|
+
for (;;) {
|
|
14
|
+
// Grow reads with an unfinished line so long lines stay linear.
|
|
15
|
+
const chunk = Buffer.alloc(Math.max(65536, rest.length));
|
|
16
|
+
const bytes = readSync(fd, chunk);
|
|
17
|
+
const data = Buffer.concat([rest, chunk.subarray(0, bytes)]);
|
|
18
|
+
const end = bytes ? data.lastIndexOf(10) + 1 : data.length;
|
|
19
|
+
for (const line of data.subarray(0, end).toString('utf8').split('\n')) {
|
|
20
|
+
let entry;
|
|
21
|
+
try { entry = JSON.parse(line); } catch { continue; }
|
|
22
|
+
if (entry) return entry.type === 'session' && typeof entry.id === 'string' ? entry.id : undefined;
|
|
23
|
+
}
|
|
24
|
+
if (!bytes) return;
|
|
25
|
+
rest = data.subarray(end);
|
|
26
|
+
}
|
|
27
|
+
} finally { closeSync(fd); }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const installed = Symbol.for('pi-agents.guard');
|
|
31
|
+
|
|
32
|
+
/** Patches Pi's Session classes so every persisted Session holds its locks. Idempotent across /reload. */
|
|
33
|
+
export function installGuard({ SessionManager, AgentSession, AgentSessionRuntime, parseSessionEntries, stateDir }) {
|
|
34
|
+
const p = SessionManager.prototype;
|
|
35
|
+
if (Object.hasOwn(p, installed)) return p[installed];
|
|
36
|
+
const states = new WeakMap();
|
|
37
|
+
const operations = new AsyncLocalStorage();
|
|
38
|
+
|
|
39
|
+
function state(sm) {
|
|
40
|
+
let value = states.get(sm);
|
|
41
|
+
if (!value) {
|
|
42
|
+
value = { held: new Map(), depth: 0, disposed: false };
|
|
43
|
+
states.set(sm, value);
|
|
44
|
+
const scope = operations.getStore();
|
|
45
|
+
if (scope?.collect) scope.managers.add(sm);
|
|
46
|
+
}
|
|
47
|
+
if (value.disposed) throw new Error('Session lock has been released');
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
function drop(resource) {
|
|
51
|
+
if (--resource.refs === 0) resource.lock.release();
|
|
52
|
+
}
|
|
53
|
+
function reserve(held, key) {
|
|
54
|
+
if (held.has(key)) return;
|
|
55
|
+
const scope = operations.getStore();
|
|
56
|
+
const borrowed = scope?.borrow?.get(key) ?? scope?.reserved?.get(key);
|
|
57
|
+
const resource = borrowed ?? { lock: acquireLock({ stateDir, sessionId: key }), refs: 0 };
|
|
58
|
+
resource.refs++;
|
|
59
|
+
held.set(key, resource);
|
|
60
|
+
}
|
|
61
|
+
function reserveFile(held, file) {
|
|
62
|
+
reserve(held, `path:${canonicalPath(file)}`);
|
|
63
|
+
const id = readSessionId(file);
|
|
64
|
+
if (id !== undefined) reserve(held, `id:${id}`);
|
|
65
|
+
}
|
|
66
|
+
function keys(sm) {
|
|
67
|
+
return [`path:${canonicalPath(sm.getSessionFile())}`, `id:${sm.getSessionId()}`];
|
|
68
|
+
}
|
|
69
|
+
function ensure(sm) {
|
|
70
|
+
if (!sm.isPersisted()) return;
|
|
71
|
+
const s = state(sm);
|
|
72
|
+
for (const key of keys(sm)) reserve(s.held, key);
|
|
73
|
+
}
|
|
74
|
+
function release(sm) {
|
|
75
|
+
if (!sm.isPersisted()) return;
|
|
76
|
+
const s = states.get(sm);
|
|
77
|
+
if (!s || s.disposed) return;
|
|
78
|
+
s.disposed = true;
|
|
79
|
+
for (const resource of s.held.values()) drop(resource);
|
|
80
|
+
s.held.clear();
|
|
81
|
+
}
|
|
82
|
+
for (const name of ['_setSessionFile', 'newSession', '_loadEntries', 'createBranchedSession']) {
|
|
83
|
+
const original = p[name];
|
|
84
|
+
p[name] = function (...args) {
|
|
85
|
+
if (!this.isPersisted()) return original.apply(this, args);
|
|
86
|
+
const s = state(this);
|
|
87
|
+
if (s.depth) return original.apply(this, args);
|
|
88
|
+
const snapshot = Object.fromEntries(Object.entries(this).map(([key, value]) => [key, value instanceof Map ? new Map(value) : value]));
|
|
89
|
+
const oldKeys = new Set(s.held.keys());
|
|
90
|
+
s.depth++;
|
|
91
|
+
try {
|
|
92
|
+
if (name === '_setSessionFile') {
|
|
93
|
+
const file = resolvePath(args[0]);
|
|
94
|
+
// Native loading can repair trailing newlines before migration.
|
|
95
|
+
reserveFile(s.held, file);
|
|
96
|
+
}
|
|
97
|
+
const result = original.apply(this, args);
|
|
98
|
+
ensure(this);
|
|
99
|
+
const keep = new Set(keys(this));
|
|
100
|
+
for (const [key, resource] of s.held) {
|
|
101
|
+
if (!keep.has(key)) { drop(resource); s.held.delete(key); }
|
|
102
|
+
}
|
|
103
|
+
return result;
|
|
104
|
+
} catch (error) {
|
|
105
|
+
Object.assign(this, snapshot);
|
|
106
|
+
for (const [key, resource] of s.held) {
|
|
107
|
+
if (!oldKeys.has(key)) { drop(resource); s.held.delete(key); }
|
|
108
|
+
}
|
|
109
|
+
throw error;
|
|
110
|
+
} finally {
|
|
111
|
+
s.depth--;
|
|
112
|
+
if (name === '_setSessionFile' && operations.getStore()) operations.getStore().borrow = undefined;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
for (const name of ['_persist', '_rewriteFile']) {
|
|
117
|
+
const original = p[name];
|
|
118
|
+
p[name] = function (...args) { ensure(this); return original.apply(this, args); };
|
|
119
|
+
}
|
|
120
|
+
const open = SessionManager.open;
|
|
121
|
+
SessionManager.open = function (file, ...args) {
|
|
122
|
+
const parent = operations.getStore();
|
|
123
|
+
const scope = { managers: parent?.managers ?? new Set(), reserved: new Map(parent?.reserved), borrow: parent?.borrow, collect: parent?.collect ?? true };
|
|
124
|
+
for (const resource of scope.reserved.values()) resource.refs++;
|
|
125
|
+
return operations.run(scope, () => {
|
|
126
|
+
let result;
|
|
127
|
+
try {
|
|
128
|
+
reserveFile(scope.reserved, resolvePath(file));
|
|
129
|
+
result = open.call(this, file, ...args);
|
|
130
|
+
return result;
|
|
131
|
+
} finally {
|
|
132
|
+
if (parent) {
|
|
133
|
+
parent.borrow = undefined;
|
|
134
|
+
// Import reservations protect the copy; the opened manager now owns it.
|
|
135
|
+
for (const resource of parent.reserved.values()) drop(resource);
|
|
136
|
+
parent.reserved.clear();
|
|
137
|
+
}
|
|
138
|
+
for (const resource of scope.reserved.values()) drop(resource);
|
|
139
|
+
if (!parent) for (const sm of scope.managers) if (sm !== result) release(sm);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
};
|
|
143
|
+
SessionManager.forkFrom = function (source, cwd, dir, options) {
|
|
144
|
+
const scope = { managers: new Set(), reserved: new Map(), collect: true };
|
|
145
|
+
return operations.run(scope, () => {
|
|
146
|
+
let result;
|
|
147
|
+
try {
|
|
148
|
+
const sourcePath = resolvePath(source);
|
|
149
|
+
// Fork history is a read-only snapshot; the source runtime may stay open.
|
|
150
|
+
const entries = parseSessionEntries(readFileSync(sourcePath, 'utf8')).filter(Boolean);
|
|
151
|
+
if (entries[0]?.type !== 'session' || typeof entries[0].id !== 'string') throw new Error(`Cannot fork: invalid session: ${sourcePath}`);
|
|
152
|
+
// Native forkFrom writes before constructing its manager. Create the native
|
|
153
|
+
// manager first so both its generated path and ID are reserved before IO.
|
|
154
|
+
const manager = SessionManager.create(cwd, dir, options);
|
|
155
|
+
const header = { ...manager.getHeader(), parentSession: sourcePath };
|
|
156
|
+
const content = [header, ...entries.filter(entry => entry.type !== 'session')];
|
|
157
|
+
writeFileSync(manager.getSessionFile(), `${content.map(entry => JSON.stringify(entry)).join('\n')}\n`, { flag: 'wx' });
|
|
158
|
+
manager.setSessionFile(manager.getSessionFile());
|
|
159
|
+
result = manager;
|
|
160
|
+
return result;
|
|
161
|
+
} finally {
|
|
162
|
+
for (const resource of scope.reserved.values()) drop(resource);
|
|
163
|
+
for (const sm of scope.managers) if (sm !== result) release(sm);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
};
|
|
167
|
+
const dispose = AgentSession.prototype.dispose;
|
|
168
|
+
AgentSession.prototype.dispose = function (...args) {
|
|
169
|
+
const result = dispose.apply(this, args);
|
|
170
|
+
release(this.sessionManager);
|
|
171
|
+
return result;
|
|
172
|
+
};
|
|
173
|
+
const rp = AgentSessionRuntime.prototype;
|
|
174
|
+
const teardown = rp.teardownCurrent;
|
|
175
|
+
rp.teardownCurrent = function (...args) {
|
|
176
|
+
const scope = operations.getStore();
|
|
177
|
+
if (scope) scope.collect = false;
|
|
178
|
+
return teardown.apply(this, args);
|
|
179
|
+
};
|
|
180
|
+
const beforeFork = rp.emitBeforeFork;
|
|
181
|
+
rp.emitBeforeFork = async function (...args) {
|
|
182
|
+
const result = await beforeFork.apply(this, args);
|
|
183
|
+
const scope = operations.getStore();
|
|
184
|
+
if (!result.cancelled && scope?.kind === 'fork') {
|
|
185
|
+
scope.collect = true;
|
|
186
|
+
scope.borrow = state(this.session.sessionManager).held;
|
|
187
|
+
}
|
|
188
|
+
return result;
|
|
189
|
+
};
|
|
190
|
+
const beforeSwitch = rp.emitBeforeSwitch;
|
|
191
|
+
rp.emitBeforeSwitch = async function (...args) {
|
|
192
|
+
const result = await beforeSwitch.apply(this, args);
|
|
193
|
+
const scope = operations.getStore();
|
|
194
|
+
if (!result.cancelled && scope) scope.collect = true;
|
|
195
|
+
if (!result.cancelled && scope?.kind === 'importFromJsonl') {
|
|
196
|
+
const file = resolvePath(scope.input);
|
|
197
|
+
reserveFile(scope.reserved, file);
|
|
198
|
+
reserve(scope.reserved, `path:${canonicalPath(resolvePath(args[1]))}`);
|
|
199
|
+
}
|
|
200
|
+
return result;
|
|
201
|
+
};
|
|
202
|
+
for (const kind of ['switchSession', 'newSession', 'fork', 'importFromJsonl']) {
|
|
203
|
+
const original = rp[kind];
|
|
204
|
+
rp[kind] = async function (...args) {
|
|
205
|
+
const previous = this.session;
|
|
206
|
+
const scope = { kind, input: args[0], managers: new Set(), reserved: new Map(), collect: false };
|
|
207
|
+
return operations.run(scope, async () => {
|
|
208
|
+
try { return await original.apply(this, args); }
|
|
209
|
+
catch (error) {
|
|
210
|
+
if (error instanceof SessionOccupiedError && this.session === previous && !states.get(previous.sessionManager)?.disposed) {
|
|
211
|
+
previous.extensionRunner.createContext().ui.notify(error.message, 'warning');
|
|
212
|
+
return { cancelled: true };
|
|
213
|
+
}
|
|
214
|
+
throw error;
|
|
215
|
+
} finally {
|
|
216
|
+
scope.borrow = undefined;
|
|
217
|
+
for (const resource of scope.reserved.values()) drop(resource);
|
|
218
|
+
scope.reserved.clear();
|
|
219
|
+
for (const sm of scope.managers) if (sm !== this.session.sessionManager) release(sm);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
const guard = { release };
|
|
225
|
+
Object.defineProperty(p, installed, { value: guard });
|
|
226
|
+
return guard;
|
|
227
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { canonicalPath, reserve } from './locks.mjs';
|
|
2
|
+
import { resolvePath, statePaths } from './paths.mjs';
|
|
3
|
+
import { readSessionId } from './guard.mjs';
|
|
4
|
+
|
|
5
|
+
// Pi reloads extension modules but keeps the SessionManager. Retain its lock
|
|
6
|
+
// across reload without allowing a different manager to borrow it.
|
|
7
|
+
const locks = globalThis[Symbol.for('pi-agents.extension-locks')] ??= new WeakMap();
|
|
8
|
+
const pending = globalThis[Symbol.for('pi-agents.pending-locks')] ??= new Map();
|
|
9
|
+
|
|
10
|
+
const switchers = globalThis[Symbol.for('pi-agents.switch-session')] ??= { byManager: new WeakMap(), patched: false };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Pi gives `switchSession` only to command handlers. Keep the one it binds to each runner, by
|
|
14
|
+
* Session manager, so a handover can switch without a command. Relies on a private member of
|
|
15
|
+
* `ExtensionRunner`; check it when Pi is updated.
|
|
16
|
+
*/
|
|
17
|
+
function captureSwitchSession(Runner) {
|
|
18
|
+
if (switchers.patched) return;
|
|
19
|
+
switchers.patched = true;
|
|
20
|
+
const bind = Runner.prototype.bindCommandContext;
|
|
21
|
+
Runner.prototype.bindCommandContext = function (actions) {
|
|
22
|
+
if (actions?.switchSession) switchers.byManager.set(this.sessionManager, actions.switchSession);
|
|
23
|
+
return bind.call(this, actions);
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Holds the current Session's lock so one runtime uses a Session at a time.
|
|
29
|
+
* An occupied Session is quarantined.
|
|
30
|
+
*/
|
|
31
|
+
export default function lockExtension(pi, runtime = {}) {
|
|
32
|
+
if (runtime.runner) captureSwitchSession(runtime.runner);
|
|
33
|
+
const paths = statePaths();
|
|
34
|
+
const stateDir = paths.locks;
|
|
35
|
+
let blocked = 'Session lock has not been acquired';
|
|
36
|
+
let reopen;
|
|
37
|
+
let replacing = false;
|
|
38
|
+
let alive = true;
|
|
39
|
+
const pendingKey = file => `${stateDir}\n${canonicalPath(file)}`;
|
|
40
|
+
function savePending(file, id, lock) {
|
|
41
|
+
const key = pendingKey(file);
|
|
42
|
+
const value = { id, lock };
|
|
43
|
+
pending.set(key, value);
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
function discardPending(file, value) {
|
|
47
|
+
if (pending.get(pendingKey(file)) !== value) return;
|
|
48
|
+
pending.delete(pendingKey(file));
|
|
49
|
+
value.lock.release();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function notify(ctx, message) {
|
|
53
|
+
if (ctx.hasUI) ctx.ui.notify(message, 'error');
|
|
54
|
+
else console.error(message);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function scheduleReopen(ctx) {
|
|
58
|
+
// Session replacement must run after the current hooks.
|
|
59
|
+
setTimeout(async () => {
|
|
60
|
+
if (!alive || !reopen) return;
|
|
61
|
+
const { file, value } = reopen;
|
|
62
|
+
reopen = undefined;
|
|
63
|
+
replacing = true;
|
|
64
|
+
try {
|
|
65
|
+
const switchSession = switchers.byManager.get(ctx.sessionManager);
|
|
66
|
+
if (!switchSession) throw new Error('Session handover is unavailable; reopen the Session with /resume.');
|
|
67
|
+
const result = await switchSession(file);
|
|
68
|
+
if (result.cancelled) {
|
|
69
|
+
notify(ctx, 'Session handover reload was cancelled.');
|
|
70
|
+
blocked = undefined;
|
|
71
|
+
}
|
|
72
|
+
} catch (error) {
|
|
73
|
+
notify(ctx, error.message);
|
|
74
|
+
blocked = undefined;
|
|
75
|
+
} finally {
|
|
76
|
+
replacing = false;
|
|
77
|
+
discardPending(file, value);
|
|
78
|
+
}
|
|
79
|
+
}, 0);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
pi.on('session_start', async (event, ctx) => {
|
|
83
|
+
const manager = ctx.sessionManager;
|
|
84
|
+
const file = manager.getSessionFile();
|
|
85
|
+
try {
|
|
86
|
+
if (file && !locks.has(manager)) {
|
|
87
|
+
const transfer = pending.get(pendingKey(file));
|
|
88
|
+
if (event.reason === 'resume' && transfer?.id === manager.getSessionId()) {
|
|
89
|
+
pending.delete(pendingKey(file));
|
|
90
|
+
locks.set(manager, transfer.lock);
|
|
91
|
+
} else {
|
|
92
|
+
// Target loading has already happened. A takeover here would leave
|
|
93
|
+
// stale shutdown writers attached to the same Session file.
|
|
94
|
+
locks.set(manager, reserve(stateDir, file, manager.getSessionId()));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (runtime.start) await runtime.start(ctx);
|
|
98
|
+
blocked = undefined;
|
|
99
|
+
} catch (error) {
|
|
100
|
+
blocked = error.message;
|
|
101
|
+
if (ctx.mode === 'tui' && error.code === 'SESSION_OCCUPIED') {
|
|
102
|
+
await ctx.ui.select(blocked, ['Quit']);
|
|
103
|
+
} else notify(ctx, blocked);
|
|
104
|
+
// A thrown session_start error is swallowed by Pi. Explicitly quarantine
|
|
105
|
+
// normal input/tool execution while the host performs graceful shutdown.
|
|
106
|
+
ctx.shutdown();
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
pi.on('session_before_switch', (event, ctx) => {
|
|
111
|
+
if (blocked && !replacing) return { cancel: true };
|
|
112
|
+
if (event.reason !== 'resume' || !event.targetSessionFile) return;
|
|
113
|
+
try {
|
|
114
|
+
const file = resolvePath(event.targetSessionFile);
|
|
115
|
+
const current = ctx.sessionManager.getSessionFile();
|
|
116
|
+
if (current && canonicalPath(file) === canonicalPath(current)) return;
|
|
117
|
+
const transfer = pending.get(pendingKey(file));
|
|
118
|
+
if (transfer) return;
|
|
119
|
+
const id = readSessionId(file);
|
|
120
|
+
const acquired = lock => {
|
|
121
|
+
if (!alive || !lock) {
|
|
122
|
+
lock?.release();
|
|
123
|
+
return { cancel: true };
|
|
124
|
+
}
|
|
125
|
+
const value = savePending(file, id, lock);
|
|
126
|
+
reopen = { file, value };
|
|
127
|
+
blocked = 'Switching to the latest Session';
|
|
128
|
+
// Switch again later so a cancellation releases the lock.
|
|
129
|
+
scheduleReopen(ctx);
|
|
130
|
+
return { cancel: true };
|
|
131
|
+
};
|
|
132
|
+
const lock = reserve(stateDir, file, id);
|
|
133
|
+
if (ctx.mode === 'tui' && id) return acquired(lock);
|
|
134
|
+
lock.release();
|
|
135
|
+
} catch (error) {
|
|
136
|
+
notify(ctx, error.message);
|
|
137
|
+
return { cancel: true };
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
pi.on('session_shutdown', async (event, ctx) => {
|
|
142
|
+
alive = false;
|
|
143
|
+
if (runtime.stop) await runtime.stop(ctx);
|
|
144
|
+
if (event.reason === 'reload') {
|
|
145
|
+
// Cancel the old continuation and release its unclaimed target lock.
|
|
146
|
+
if (reopen) discardPending(reopen.file, reopen.value);
|
|
147
|
+
reopen = undefined;
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (event.reason === 'quit' && reopen) {
|
|
151
|
+
discardPending(reopen.file, reopen.value);
|
|
152
|
+
reopen = undefined;
|
|
153
|
+
}
|
|
154
|
+
locks.get(ctx.sessionManager)?.release();
|
|
155
|
+
locks.delete(ctx.sessionManager);
|
|
156
|
+
});
|
|
157
|
+
pi.on('input', (_event, ctx) => {
|
|
158
|
+
if (!blocked) return;
|
|
159
|
+
notify(ctx, blocked);
|
|
160
|
+
return { action: 'handled' };
|
|
161
|
+
});
|
|
162
|
+
pi.on('tool_call', () => blocked ? { block: true, reason: blocked, terminate: true } : undefined);
|
|
163
|
+
pi.on('user_bash', () => blocked ? { result: { output: blocked, exitCode: 1, cancelled: true, truncated: false } } : undefined);
|
|
164
|
+
for (const event of ['session_before_compact', 'session_before_tree', 'session_before_fork']) {
|
|
165
|
+
pi.on(event, () => blocked ? { cancel: true } : undefined);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { closeSync, constants, lstatSync, mkdirSync, openSync, readdirSync, realpathSync, unlinkSync } from 'node:fs';
|
|
4
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
5
|
+
|
|
6
|
+
/** The Session ID of a lock key: `id:<id>`, or `path:<file>` whose name ends with `_<id>.jsonl`. */
|
|
7
|
+
function sessionLabel(key) {
|
|
8
|
+
if (key.startsWith('id:')) return key.slice(3);
|
|
9
|
+
return basename(key.slice(5), '.jsonl').split('_').pop();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class SessionOccupiedError extends Error {
|
|
13
|
+
constructor(sessionId, owner) {
|
|
14
|
+
const label = sessionLabel(sessionId);
|
|
15
|
+
super(`[pi-agents] Session ${label} is in use${owner ? ` (PID ${owner.pid})` : ''}. Quit the other Pi instance.`);
|
|
16
|
+
this.name = 'SessionOccupiedError';
|
|
17
|
+
this.code = 'SESSION_OCCUPIED';
|
|
18
|
+
this.sessionId = sessionId;
|
|
19
|
+
this.owner = owner;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Each contender for a lock creates its own file, `<key hash>.<pid>-<start>.lock`, and holds the
|
|
24
|
+
// lock if no other live process has such a file. Only its creator or a pruner of a dead process
|
|
25
|
+
// removes a file, so a holder's file never disappears under it.
|
|
26
|
+
const RETRIES = 3;
|
|
27
|
+
|
|
28
|
+
function pidAlive(pid) {
|
|
29
|
+
try { process.kill(pid, 0); return true; }
|
|
30
|
+
catch (error) { if (error.code === 'ESRCH') return false; if (error.code === 'EPERM') return true; throw error; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** When the process started, as ps reports it, or NaN without ps. */
|
|
34
|
+
function startOf(pid) {
|
|
35
|
+
const ps = spawnSync('/bin/ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8', env: { ...process.env, LC_ALL: 'C' } });
|
|
36
|
+
return ps.status === 0 ? Date.parse(ps.stdout.trim()) : NaN;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The process's own start comes from ps too, since a wrapper may have exec'd Node long after the PID began.
|
|
40
|
+
// Without ps it is unknown (0), and others then trust the PID alone.
|
|
41
|
+
let selfStart;
|
|
42
|
+
const ownStart = () => selfStart ??= startOf(process.pid) || 0;
|
|
43
|
+
|
|
44
|
+
/** Whether a lock's holder still runs. A process that reused its PID started later; without a known start, the PID is trusted. */
|
|
45
|
+
function alive(owner) {
|
|
46
|
+
if (owner.pid === process.pid) return owner.start === ownStart();
|
|
47
|
+
if (!pidAlive(owner.pid)) return false;
|
|
48
|
+
if (owner.start === 0) return true;
|
|
49
|
+
const started = startOf(owner.pid);
|
|
50
|
+
return Number.isNaN(started) || Math.abs(owner.start - started) < 3000;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const keyHash = sessionId => createHash('sha256').update(sessionId).digest('hex');
|
|
54
|
+
|
|
55
|
+
/** The lock files in `stateDir`, optionally only those of one key, with their holders. */
|
|
56
|
+
function lockFiles(stateDir, hash) {
|
|
57
|
+
let names;
|
|
58
|
+
try { names = readdirSync(stateDir); }
|
|
59
|
+
catch (error) { if (error.code === 'ENOENT') return []; throw error; }
|
|
60
|
+
return names.flatMap(name => {
|
|
61
|
+
const match = /^([0-9a-f]{64})\.(\d+)-(\d+)\.lock$/.exec(name);
|
|
62
|
+
if (!match || (hash && match[1] !== hash)) return [];
|
|
63
|
+
return [{ path: join(stateDir, name), owner: { pid: Number(match[2]), start: Number(match[3]) } }];
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function removeFile(path) {
|
|
68
|
+
try { unlinkSync(path); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const pause = new Int32Array(new SharedArrayBuffer(4));
|
|
72
|
+
|
|
73
|
+
function createLock({ stateDir, sessionId }) {
|
|
74
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) throw new Error('Session ID is required');
|
|
75
|
+
mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
76
|
+
const directory = lstatSync(stateDir);
|
|
77
|
+
if (!directory.isDirectory() || directory.uid !== process.getuid() || (directory.mode & 0o077) !== 0) {
|
|
78
|
+
throw new Error(`Lock directory must be a private directory owned by the current user: ${stateDir}`);
|
|
79
|
+
}
|
|
80
|
+
const hash = keyHash(sessionId);
|
|
81
|
+
const owner = { pid: process.pid, start: ownStart() };
|
|
82
|
+
const path = join(stateDir, `${hash}.${owner.pid}-${owner.start}.lock`);
|
|
83
|
+
for (let attempt = 0; ; attempt++) {
|
|
84
|
+
closeSync(openSync(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600));
|
|
85
|
+
let other;
|
|
86
|
+
for (const lock of lockFiles(stateDir, hash)) {
|
|
87
|
+
if (lock.path === path) continue;
|
|
88
|
+
if (alive(lock.owner)) other ??= lock;
|
|
89
|
+
else removeFile(lock.path);
|
|
90
|
+
}
|
|
91
|
+
if (!other) return { owner, release() { removeFile(path); } };
|
|
92
|
+
removeFile(path);
|
|
93
|
+
// Contenders that see each other both back off; a short random pause lets one of them win.
|
|
94
|
+
if (attempt === RETRIES) throw new SessionOccupiedError(sessionId, other.owner);
|
|
95
|
+
Atomics.wait(pause, 0, 0, 5 + Math.random() * 20);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Removes the lock files of processes that ended without releasing them. */
|
|
100
|
+
export function pruneLocks(stateDir) {
|
|
101
|
+
for (const lock of lockFiles(stateDir)) if (!alive(lock.owner)) removeFile(lock.path);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// A process's own lock reads as held, so the extension, the guard and a
|
|
105
|
+
// /resume wait share each lock through one reference-counted table.
|
|
106
|
+
const locks = globalThis[Symbol.for('pi-agents.locks')] ??= new Map();
|
|
107
|
+
|
|
108
|
+
const tableKey = (stateDir, sessionId) => `${resolve(stateDir)}\n${sessionId}`;
|
|
109
|
+
|
|
110
|
+
export function acquireLock(options) {
|
|
111
|
+
const key = tableKey(options.stateDir, options.sessionId);
|
|
112
|
+
let entry = locks.get(key);
|
|
113
|
+
if (!entry) {
|
|
114
|
+
entry = { lock: createLock(options), refs: 0 };
|
|
115
|
+
locks.set(key, entry);
|
|
116
|
+
}
|
|
117
|
+
entry.refs++;
|
|
118
|
+
let released = false;
|
|
119
|
+
return {
|
|
120
|
+
owner: entry.lock.owner,
|
|
121
|
+
release() {
|
|
122
|
+
if (released) return;
|
|
123
|
+
released = true;
|
|
124
|
+
if (--entry.refs > 0) return;
|
|
125
|
+
locks.delete(key);
|
|
126
|
+
entry.lock.release();
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function canonicalPath(path) {
|
|
132
|
+
try { return realpathSync(path); }
|
|
133
|
+
catch (error) {
|
|
134
|
+
if (error.code !== 'ENOENT') throw error;
|
|
135
|
+
return join(canonicalPath(dirname(path)), basename(path));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function lockKeys(sessionFile, id) {
|
|
140
|
+
return [`path:${canonicalPath(sessionFile)}`, ...(id ? [`id:${id}`] : [])];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function reserveKeys(stateDir, keys) {
|
|
144
|
+
const held = [];
|
|
145
|
+
try {
|
|
146
|
+
for (const sessionId of keys) held.push(acquireLock({ stateDir, sessionId }));
|
|
147
|
+
} catch (error) {
|
|
148
|
+
for (const lock of held) lock.release();
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
return { release() { for (const lock of held) lock.release(); } };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// This process may acquire its own locks again, so only another process can hold them.
|
|
155
|
+
function heldElsewhere(stateDir, sessionId) {
|
|
156
|
+
if (locks.has(tableKey(stateDir, sessionId))) return false;
|
|
157
|
+
return lockFiles(stateDir, keyHash(sessionId)).some(lock => alive(lock.owner));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Whether `reserve` would find the Session occupied, without creating or acquiring its locks. */
|
|
161
|
+
export function isLocked(stateDir, sessionFile, id) {
|
|
162
|
+
return lockKeys(sessionFile, id).some(key => heldElsewhere(stateDir, key));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function reserve(stateDir, sessionFile, id) {
|
|
166
|
+
return reserveKeys(stateDir, lockKeys(sessionFile, id));
|
|
167
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { lstat, mkdir } from 'node:fs/promises';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { join, resolve } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
// Pi does not export its resolvePath; session paths only need ~ and file:// handling.
|
|
9
|
+
export function resolvePath(input) {
|
|
10
|
+
const path = input.startsWith('file://') ? fileURLToPath(input) : input;
|
|
11
|
+
return resolve(path === '~' ? homedir() : path.startsWith('~/') ? join(homedir(), path.slice(2)) : path);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Kept data lives in the state directory; locks and sockets, which live only while their process
|
|
16
|
+
* does, in `$XDG_RUNTIME_DIR` (macOS has none, so `runtime/` in the state directory).
|
|
17
|
+
*/
|
|
18
|
+
export function statePaths(env = process.env) {
|
|
19
|
+
const state = env.PI_AGENTS_STATE_DIR
|
|
20
|
+
? resolve(env.PI_AGENTS_STATE_DIR)
|
|
21
|
+
: join(env.XDG_STATE_HOME ?? join(homedir(), '.local/state'), 'pi/agents');
|
|
22
|
+
const runtime = !env.PI_AGENTS_STATE_DIR && env.XDG_RUNTIME_DIR ? join(env.XDG_RUNTIME_DIR, 'pi-agents') : join(state, 'runtime');
|
|
23
|
+
return {
|
|
24
|
+
state,
|
|
25
|
+
runtime,
|
|
26
|
+
workbench: join(state, 'workbench.json'),
|
|
27
|
+
hostLog: join(state, 'host.log'),
|
|
28
|
+
daemonLog: join(state, 'daemon.log'),
|
|
29
|
+
host(pid) { return join(runtime, `h-${pid}.sock`); },
|
|
30
|
+
locks: join(runtime, 'locks'),
|
|
31
|
+
daemon: join(runtime, 'daemon.sock'),
|
|
32
|
+
session(id) { return join(runtime, `s-${createHash('sha256').update(id).digest('hex').slice(0, 24)}.sock`); },
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Pi's agent directory: PI_CODING_AGENT_DIR, or ~/.pi/agent. */
|
|
37
|
+
export function agentDir(env = process.env) {
|
|
38
|
+
return env.PI_CODING_AGENT_DIR ? resolvePath(env.PI_CODING_AGENT_DIR) : join(homedir(), '.pi/agent');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The session directory configured without `--session-dir`: PI_CODING_AGENT_SESSION_DIR, then the global `sessionDir` setting. */
|
|
42
|
+
export function configuredSessionDir(env = process.env) {
|
|
43
|
+
if (env.PI_CODING_AGENT_SESSION_DIR) return resolvePath(env.PI_CODING_AGENT_SESSION_DIR);
|
|
44
|
+
let setting;
|
|
45
|
+
try { setting = JSON.parse(readFileSync(join(agentDir(env), 'settings.json'), 'utf8').replace(/^\uFEFF/, '')).sessionDir; }
|
|
46
|
+
catch (error) { if (error.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error; }
|
|
47
|
+
if (typeof setting === 'string' && setting) return resolvePath(setting);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Pi's session directory without `--session-dir`: the configured one, or `sessions/` in the agent directory. */
|
|
51
|
+
export function sessionDir(env = process.env) {
|
|
52
|
+
return configuredSessionDir(env) ?? join(agentDir(env), 'sessions');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The directory Pi keeps a cwd's Sessions in when none is configured. */
|
|
56
|
+
export function defaultSessionDir(cwd, env = process.env) {
|
|
57
|
+
return join(agentDir(env), 'sessions', `--${resolve(cwd).replace(/^\//, '').replace(/[/\\:]/g, '-')}--`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function prepareDirectory(directory) {
|
|
61
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
62
|
+
const info = await lstat(directory);
|
|
63
|
+
if (!info.isDirectory() || info.uid !== process.getuid() || (info.mode & 0o077)) {
|
|
64
|
+
throw new Error(`Agent runtime directory must be private and owned by the current user: ${directory}`);
|
|
65
|
+
}
|
|
66
|
+
}
|