pikiloom 0.4.68 → 0.4.69

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pikiloom",
3
- "version": "0.4.68",
3
+ "version": "0.4.69",
4
4
  "description": "Put the world's smartest AI agents in your pocket. Command local Claude & Gemini via IM. | 让最好用的 IM 变成你电脑上的顶级 Agent 控制台",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,6 +14,8 @@ export interface CoreSessionRecord {
14
14
  effort?: string | null;
15
15
  runState?: 'running' | 'completed' | 'incomplete';
16
16
  runDetail?: string | null;
17
+ runPid?: number | null;
18
+ runStartedAt?: number | null;
17
19
  }
18
20
  export interface SessionStore {
19
21
  ensure(agent: string, opts: {
@@ -30,6 +32,11 @@ export interface SessionStore {
30
32
  limit?: number;
31
33
  }): Promise<CoreSessionRecord[]>;
32
34
  recordResult(agent: string, sessionId: string, result: DriverResult): Promise<void>;
35
+ markRunning?(agent: string, sessionId: string, owner: {
36
+ pid: number;
37
+ startedAt: number;
38
+ }): Promise<void>;
39
+ reconcileRunning?(isAlive: (pid: number) => boolean): Promise<number>;
33
40
  appendTurn?(agent: string, sessionId: string, turn: UniversalSnapshot): Promise<void>;
34
41
  history?(agent: string, sessionId: string): Promise<UniversalSnapshot[]>;
35
42
  }
@@ -263,8 +263,25 @@ export class CodexDriver {
263
263
  let steerRegistered = false;
264
264
  if (!srv.start())
265
265
  return { ok: false, text: '', error: 'failed to start codex app-server', stopReason: 'error' };
266
- let settle = () => { };
267
- const turnDone = new Promise((res) => { settle = res; });
266
+ let settled = false;
267
+ let resolveTurn = () => { };
268
+ const turnDone = new Promise((res) => { resolveTurn = res; });
269
+ // Idempotent: turnDone can be settled from three racing sources (turn/completed, abort,
270
+ // process death); the first wins and the rest are no-ops.
271
+ const settle = () => { if (settled)
272
+ return; settled = true; resolveTurn(); };
273
+ // If the app-server process dies WITHOUT a turn/completed (crash, kill, disconnect), the
274
+ // `await turnDone` below would otherwise hang forever — run() never resolves, recordResult
275
+ // never fires, and the session is stranded runState:"running" in the orchestrator even though
276
+ // the codex process is already dead. Settle it as a failed turn so the orchestrator can
277
+ // finalize it (mirrors the claude driver's child.on('close') settle).
278
+ srv.onClose(() => {
279
+ if (settled)
280
+ return;
281
+ state.error = state.error || srv.stderrText().trim().split('\n').pop() || 'codex app-server exited before the turn completed';
282
+ state.status = 'error';
283
+ settle();
284
+ });
268
285
  // On abort: gracefully interrupt the running turn, then settle turnDone OURSELVES. A bare
269
286
  // srv.kill() (SIGTERM) never produces a turn/completed notification, so without this explicit
270
287
  // settle() the `await turnDone` below hangs forever — run() never resolves and the task stays
@@ -25,12 +25,19 @@ export declare class StdioRpcClient {
25
25
  private readonly pending;
26
26
  private notifyCb?;
27
27
  private requestCb?;
28
+ private closeCb?;
28
29
  private readonly stderrTail;
29
30
  private readonly label;
30
31
  constructor(opts: StdioRpcOptions);
31
32
  onNotification(cb: (method: string, params: any) => void): void;
32
33
  /** Serve peer->client requests. Throw RpcError for a coded error; anything else answers -32603. */
33
34
  onRequest(cb: (method: string, params: any, id: number | string) => any | Promise<any>): void;
35
+ /**
36
+ * Fires once when the child process exits/closes. A driver awaiting a terminal *notification*
37
+ * (not a pending request) MUST use this to settle its turn — otherwise a process that dies
38
+ * without emitting its completion notification leaves the turn hanging forever.
39
+ */
40
+ onClose(cb: () => void): void;
34
41
  /** Rolling tail of the process' stderr — startup/crash diagnostics. */
35
42
  stderrText(): string;
36
43
  start(): boolean;
@@ -16,6 +16,7 @@ export class StdioRpcClient {
16
16
  pending = new Map();
17
17
  notifyCb;
18
18
  requestCb;
19
+ closeCb;
19
20
  stderrTail = [];
20
21
  label;
21
22
  constructor(opts) {
@@ -25,6 +26,12 @@ export class StdioRpcClient {
25
26
  onNotification(cb) { this.notifyCb = cb; }
26
27
  /** Serve peer->client requests. Throw RpcError for a coded error; anything else answers -32603. */
27
28
  onRequest(cb) { this.requestCb = cb; }
29
+ /**
30
+ * Fires once when the child process exits/closes. A driver awaiting a terminal *notification*
31
+ * (not a pending request) MUST use this to settle its turn — otherwise a process that dies
32
+ * without emitting its completion notification leaves the turn hanging forever.
33
+ */
34
+ onClose(cb) { this.closeCb = cb; }
28
35
  /** Rolling tail of the process' stderr — startup/crash diagnostics. */
29
36
  stderrText() { return this.stderrTail.join('\n'); }
30
37
  start() {
@@ -55,6 +62,7 @@ export class StdioRpcClient {
55
62
  for (const cb of this.pending.values())
56
63
  cb({ error: { message: `${this.label} exited` } });
57
64
  this.pending.clear();
65
+ this.closeCb?.();
58
66
  });
59
67
  this.proc.on('error', () => { });
60
68
  return true;
@@ -25,9 +25,21 @@ export declare class FsSessionStore implements SessionStore {
25
25
  text?: string;
26
26
  sessionId?: string | null;
27
27
  }): Promise<void>;
28
+ markRunning(agent: string, sessionId: string, owner: {
29
+ pid: number;
30
+ startedAt: number;
31
+ }): Promise<void>;
32
+ reconcileRunning(isAlive: (pid: number) => boolean): Promise<number>;
28
33
  appendTurn(agent: string, sessionId: string, turn: UniversalSnapshot): Promise<void>;
29
34
  history(agent: string, sessionId: string): Promise<UniversalSnapshot[]>;
30
35
  }
36
+ /**
37
+ * Is a pid currently a live process? `signal 0` probes without delivering a signal: ESRCH =>
38
+ * no such process (dead); EPERM => alive but owned by someone we can't signal (treat as alive).
39
+ * The conservative bias (unknown => alive) is deliberate — reconciliation must never reap a turn
40
+ * that might still be running.
41
+ */
42
+ export declare function isProcessAlive(pid: number): boolean;
31
43
  export declare function defaultBaseDir(appNamespace: string): string;
32
44
  export declare class NullModelResolver implements ModelResolver {
33
45
  resolve(): Promise<null>;
@@ -74,6 +74,7 @@ export class FsSessionStore {
74
74
  return;
75
75
  rec.runState = result.ok ? 'completed' : 'incomplete';
76
76
  rec.runDetail = result.error ?? null;
77
+ rec.runPid = null; // turn is over — drop the owner so reconciliation ignores it
77
78
  if (result.sessionId)
78
79
  rec.nativeSessionId = result.sessionId;
79
80
  if (result.text && !rec.title)
@@ -82,6 +83,43 @@ export class FsSessionStore {
82
83
  rec.preview = result.text.replace(/\s+/g, ' ').trim().slice(0, 200) || rec.preview || null;
83
84
  await this.save(rec);
84
85
  }
86
+ async markRunning(agent, sessionId, owner) {
87
+ const rec = await this.get(agent, sessionId);
88
+ if (!rec)
89
+ return;
90
+ rec.runState = 'running';
91
+ rec.runDetail = null;
92
+ rec.runPid = owner.pid;
93
+ rec.runStartedAt = owner.startedAt;
94
+ await this.save(rec);
95
+ }
96
+ async reconcileRunning(isAlive) {
97
+ let agents = [];
98
+ try {
99
+ agents = fs.readdirSync(this.baseDir, { withFileTypes: true }).filter(d => d.isDirectory()).map(d => d.name);
100
+ }
101
+ catch {
102
+ return 0;
103
+ }
104
+ let repaired = 0;
105
+ for (const agent of agents) {
106
+ for (const rec of await this.list(agent)) {
107
+ if (rec.runState !== 'running')
108
+ continue;
109
+ // Only reap a KNOWN-dead owner. No pid (legacy record) or a live pid (possibly a turn
110
+ // running in another process against this shared store) is left alone — never clobber a
111
+ // turn that might still be live elsewhere.
112
+ if (typeof rec.runPid !== 'number' || isAlive(rec.runPid))
113
+ continue;
114
+ rec.runState = 'incomplete';
115
+ rec.runDetail = rec.runDetail || 'interrupted: owner process exited';
116
+ rec.runPid = null;
117
+ await this.save(rec);
118
+ repaired++;
119
+ }
120
+ }
121
+ return repaired;
122
+ }
85
123
  async appendTurn(agent, sessionId, turn) {
86
124
  const p = this.turnsPath(agent, sessionId);
87
125
  fs.mkdirSync(path.dirname(p), { recursive: true });
@@ -108,6 +146,23 @@ export class FsSessionStore {
108
146
  return out;
109
147
  }
110
148
  }
149
+ /**
150
+ * Is a pid currently a live process? `signal 0` probes without delivering a signal: ESRCH =>
151
+ * no such process (dead); EPERM => alive but owned by someone we can't signal (treat as alive).
152
+ * The conservative bias (unknown => alive) is deliberate — reconciliation must never reap a turn
153
+ * that might still be running.
154
+ */
155
+ export function isProcessAlive(pid) {
156
+ if (!Number.isInteger(pid) || pid <= 0)
157
+ return false;
158
+ try {
159
+ process.kill(pid, 0);
160
+ return true;
161
+ }
162
+ catch (e) {
163
+ return e?.code === 'EPERM';
164
+ }
165
+ }
111
166
  export function defaultBaseDir(appNamespace) {
112
167
  return path.join(os.homedir(), `.${appNamespace}`, 'sessions');
113
168
  }
@@ -94,6 +94,10 @@ export class Hub {
94
94
  };
95
95
  this.sessions.set(sessionKey, entry);
96
96
  this.emitSessionsChanged();
97
+ // Stamp the persisted record as running under THIS process, so runState is authoritative for
98
+ // the whole turn (new AND resumed) and a crash strands an owner pid that boot reconciliation
99
+ // can reap. Best-effort: a store without markRunning just won't be reconcilable.
100
+ await this.deps.sessionStore.markRunning?.(agent, sessionId, { pid: process.pid, startedAt: Date.now() }).catch(() => { });
97
101
  // Resolve injection / tools / resume-target at run time so a promoted turn sees the
98
102
  // prior turn's native session id and any refreshed credentials/tools.
99
103
  const rec = await this.deps.sessionStore.get(agent, sessionId);
@@ -1,4 +1,4 @@
1
- import { FsSessionStore, NullModelResolver, NoopToolProvider, PassthroughSystemPromptBuilder, NoopCatalog, DeferToTerminalInteractionHandler, defaultBaseDir, } from '../ports/defaults.js';
1
+ import { FsSessionStore, NullModelResolver, NoopToolProvider, PassthroughSystemPromptBuilder, NoopCatalog, DeferToTerminalInteractionHandler, defaultBaseDir, isProcessAlive, } from '../ports/defaults.js';
2
2
  import { Hub } from './hub.js';
3
3
  import { PtyBridge } from './pty.js';
4
4
  import { attachTui } from './tui.js';
@@ -59,6 +59,17 @@ export function createLoom(config = {}) {
59
59
  if (started)
60
60
  return;
61
61
  started = true;
62
+ // Repair sessions stranded at runState:'running' by a previous process that died mid-turn
63
+ // (crash / kill / power loss) before its driver could settle. Safe under a shared store:
64
+ // only records whose owner pid is dead are reaped (see reconcileRunning). Best-effort.
65
+ try {
66
+ const repaired = await sessionStore.reconcileRunning?.(isProcessAlive);
67
+ if (repaired)
68
+ log(`[loom] reconciled ${repaired} orphaned running session(s) at startup`);
69
+ }
70
+ catch (e) {
71
+ log(`[loom] startup reconcile failed: ${e?.message || e}`);
72
+ }
62
73
  for (const t of surfaces) {
63
74
  await t.start(hub, { openTui }); // hub = Lane S (LoomIO); openTui = Lane R
64
75
  log(`[loom] terminal started: ${t.id}`);