@awak-app/simy-cli 0.5.65 → 0.5.67

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": "@awak-app/simy-cli",
3
- "version": "0.5.65",
3
+ "version": "0.5.67",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,7 +13,7 @@ import {
13
13
  } from "./durable-local-task-steps-contract.js";
14
14
  import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
15
15
 
16
- export const CLI_VERSION = "0.5.65";
16
+ export const CLI_VERSION = "0.5.67";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function withCliContract(
@@ -1,4 +1,5 @@
1
1
  {
2
+ "description": "SIMY connection reminders, opt-in Delivery Loop lifecycle tracking, and development progress reporting. Trusting hooks does not enable automatic continuation for a task.",
2
3
  "hooks": {
3
4
  "SessionStart": [
4
5
  {
@@ -7,7 +8,8 @@
7
8
  {
8
9
  "type": "command",
9
10
  "command": "node --input-type=module -e \"import { pathToFileURL } from 'node:url'; import { join } from 'node:path'; const hook = await import(pathToFileURL(join(process.env.PLUGIN_ROOT, 'hooks', 'connection-reminder.mjs')).href); await hook.main();\"",
10
- "timeout": 3
11
+ "timeout": 3,
12
+ "statusMessage": "SIMY connection check at session start"
11
13
  }
12
14
  ]
13
15
  },
@@ -16,7 +18,8 @@
16
18
  {
17
19
  "type": "command",
18
20
  "command": "node --input-type=module -e \"import { pathToFileURL } from 'node:url'; import { join } from 'node:path'; const hook = await import(pathToFileURL(join(process.env.PLUGIN_ROOT, 'hooks', 'delivery-loop.mjs')).href); await hook.main('codex');\"",
19
- "timeout": 3
21
+ "timeout": 3,
22
+ "statusMessage": "SIMY Delivery Loop session tracking"
20
23
  }
21
24
  ]
22
25
  }
@@ -27,7 +30,8 @@
27
30
  {
28
31
  "type": "command",
29
32
  "command": "node --input-type=module -e \"import { pathToFileURL } from 'node:url'; import { join } from 'node:path'; const hook = await import(pathToFileURL(join(process.env.PLUGIN_ROOT, 'hooks', 'connection-reminder.mjs')).href); await hook.main();\"",
30
- "timeout": 3
33
+ "timeout": 3,
34
+ "statusMessage": "SIMY connection check before a prompt"
31
35
  }
32
36
  ]
33
37
  },
@@ -36,7 +40,8 @@
36
40
  {
37
41
  "type": "command",
38
42
  "command": "node --input-type=module -e \"import { pathToFileURL } from 'node:url'; import { join } from 'node:path'; const hook = await import(pathToFileURL(join(process.env.PLUGIN_ROOT, 'hooks', 'delivery-loop.mjs')).href); await hook.main('codex');\"",
39
- "timeout": 3
43
+ "timeout": 3,
44
+ "statusMessage": "SIMY Delivery Loop prompt tracking"
40
45
  }
41
46
  ]
42
47
  }
@@ -47,7 +52,8 @@
47
52
  {
48
53
  "type": "command",
49
54
  "command": "node --input-type=module -e \"import { pathToFileURL } from 'node:url'; import { join } from 'node:path'; const hook = await import(pathToFileURL(join(process.env.PLUGIN_ROOT, 'hooks', 'delivery-loop.mjs')).href); await hook.main('codex');\"",
50
- "timeout": 3
55
+ "timeout": 3,
56
+ "statusMessage": "SIMY Delivery Loop remaining-work check"
51
57
  }
52
58
  ]
53
59
  },
@@ -56,7 +62,8 @@
56
62
  {
57
63
  "type": "command",
58
64
  "command": "node --input-type=module -e \"import { pathToFileURL } from 'node:url'; import { join } from 'node:path'; const hook = await import(pathToFileURL(join(process.env.PLUGIN_ROOT, 'hooks', 'dev-work.mjs')).href); await hook.main('codex');\"",
59
- "timeout": 10
65
+ "timeout": 10,
66
+ "statusMessage": "SIMY development progress reporting"
60
67
  }
61
68
  ]
62
69
  }
@@ -67,7 +74,8 @@
67
74
  {
68
75
  "type": "command",
69
76
  "command": "node --input-type=module -e \"import { pathToFileURL } from 'node:url'; import { join } from 'node:path'; const hook = await import(pathToFileURL(join(process.env.PLUGIN_ROOT, 'hooks', 'delivery-loop.mjs')).href); await hook.main('codex');\"",
70
- "timeout": 3
77
+ "timeout": 3,
78
+ "statusMessage": "SIMY Delivery Loop session-exit tracking"
71
79
  }
72
80
  ]
73
81
  }
@@ -78,7 +86,8 @@
78
86
  {
79
87
  "type": "command",
80
88
  "command": "node --input-type=module -e \"import { pathToFileURL } from 'node:url'; import { join } from 'node:path'; const hook = await import(pathToFileURL(join(process.env.PLUGIN_ROOT, 'hooks', 'delivery-loop.mjs')).href); await hook.main('codex');\"",
81
- "timeout": 3
89
+ "timeout": 3,
90
+ "statusMessage": "SIMY Delivery Loop user-stop tracking"
82
91
  }
83
92
  ]
84
93
  }
@@ -89,7 +98,8 @@
89
98
  {
90
99
  "type": "command",
91
100
  "command": "node --input-type=module -e \"import { pathToFileURL } from 'node:url'; import { join } from 'node:path'; const hook = await import(pathToFileURL(join(process.env.PLUGIN_ROOT, 'hooks', 'delivery-loop.mjs')).href); await hook.main('codex');\"",
92
- "timeout": 3
101
+ "timeout": 3,
102
+ "statusMessage": "SIMY Delivery Loop permission-wait tracking"
93
103
  }
94
104
  ]
95
105
  }
@@ -3,6 +3,7 @@ import { lstat, open, readdir, realpath } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
4
  import { isAbsolute, join, relative, resolve, sep } from 'node:path';
5
5
  import { boundConversation, CONVERSATION_LIMITS, textContent } from './conversation-context.js';
6
+ import { sessionTitle } from './session-titles.js';
6
7
 
7
8
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
8
9
  const MAX_PROJECTS = 256;
@@ -54,21 +55,41 @@ function metadataFromPrefix(bytes, id) {
54
55
  const text = bytes.toString('utf8');
55
56
  const complete = text.slice(0, text.lastIndexOf('\n') + 1);
56
57
  if (!complete) return null;
57
- let cwd = '', title = '', found = false;
58
+ let cwd = '', title = '', customTitle = '', found = false;
58
59
  for (const line of complete.split('\n').slice(0, MAX_LINES)) {
59
60
  if (!line) continue;
60
61
  let item;
61
62
  try { item = JSON.parse(line); } catch { return null; }
62
63
  if (item.sessionId !== id) return null;
64
+ if (item.type === 'custom-title' && item.isSidechain !== true) { customTitle = sessionTitle(item.customTitle); continue; }
63
65
  if (item.type !== 'user' && item.type !== 'assistant') continue;
64
66
  if (item.isSidechain === true) return null;
65
67
  if (typeof item.cwd !== 'string' || !isAbsolute(item.cwd)) continue;
66
68
  if (!cwd) cwd = resolve(item.cwd);
67
69
  if (cwd !== resolve(item.cwd)) return null;
68
- if (!title && typeof item.slug === 'string') title = item.slug.replace(/[\x00-\x1f\x7f]/g, '').slice(0, 120);
70
+ if (!title) title = sessionTitle(item.slug);
69
71
  found = true;
70
72
  }
71
- return found ? { cwd, title } : null;
73
+ return found ? { cwd, title: customTitle || title } : null;
74
+ }
75
+
76
+ // /rename appends a custom-title envelope, often beyond the bounded prefix.
77
+ // Inspect only those envelopes; conversation text is never used as a title.
78
+ async function latestCustomTitle(handle, size, id) {
79
+ const offset = Math.max(0, size - 64 * 1024);
80
+ const buffer = Buffer.alloc(Math.min(size, 64 * 1024));
81
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
82
+ let text = buffer.subarray(0, bytesRead).toString('utf8');
83
+ if (offset) text = text.slice(text.indexOf('\n') + 1);
84
+ text = text.slice(0, text.lastIndexOf('\n') + 1);
85
+ let title = '';
86
+ for (const line of text.split('\n')) {
87
+ try {
88
+ const item = JSON.parse(line);
89
+ if (item.type === 'custom-title' && item.sessionId === id && item.isSidechain !== true) title = sessionTitle(item.customTitle);
90
+ } catch { /* Ignore partial or unrelated tail entries. */ }
91
+ }
92
+ return title;
72
93
  }
73
94
 
74
95
  async function inspectTranscript(root, projectName, fileName, observedAt, includeContext = false, cacheMetadata = false) {
@@ -104,6 +125,7 @@ async function inspectTranscript(root, projectName, fileName, observedAt, includ
104
125
  const buffer = Buffer.allocUnsafe(Math.min(opened.size, MAX_PREFIX_BYTES));
105
126
  const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
106
127
  const metadata = metadataFromPrefix(buffer.subarray(0, bytesRead), id);
128
+ if (metadata) metadata.title = await latestCustomTitle(handle, opened.size, id) || metadata.title;
107
129
  if (cacheMetadata) {
108
130
  const [after, pathAfter] = await Promise.all([handle.stat({ bigint: true }), lstat(path, { bigint: true })]);
109
131
  if (fingerprint(after) === key && fingerprint(pathAfter) === key && pathAfter.isFile() && !pathAfter.isSymbolicLink())
@@ -4,6 +4,7 @@ import { TERMINAL as TERMINAL_RUNS, controlDelivery, listRecords, readRecord, pr
4
4
  import { listCodexSessions, openCodexAppServer, readCodexSession, CodexAppServerError } from './codex-app-server.js';
5
5
  import { listClaudeSessions, readClaudeSession } from './claude-sessions.js';
6
6
  import { originalSessionTarget } from './original-session-target.js';
7
+ import { readCodexTitles, sessionTitle } from './session-titles.js';
7
8
 
8
9
  function autoContinueState(record, isRunning, supported) {
9
10
  if (record?.autoContinue !== true) return { autoContinueStatus: 'off', autoContinueReason: null };
@@ -55,7 +56,7 @@ async function liveClaudeHook(record, metadata, inspectProcess) {
55
56
  }
56
57
 
57
58
  export async function discoverSessions({ root, openClient = openCodexAppServer, listClaude = listClaudeSessions,
58
- inspectProcess = processIdentity, now = Date.now(), signal } = {}) {
59
+ inspectProcess = processIdentity, now = Date.now(), signal, listCodexTitles = readCodexTitles } = {}) {
59
60
  const assertActive = () => { if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation discovery was cancelled.'); };
60
61
  assertActive();
61
62
  const records = await listRecords(root);
@@ -81,6 +82,8 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
81
82
  // Historical sessions must not fan out hundreds of process-inspection
82
83
  // subprocesses. Preserve input ordering while limiting live metadata probes.
83
84
  const items = [...byKey.values()], projected = new Array(items.length);
85
+ const codexTitles = items.some(item => item.provider === 'codex' && !sessionTitle(item.title)) ? await listCodexTitles() : new Map();
86
+ assertActive();
84
87
  let nextItem = 0;
85
88
  const project = async item => {
86
89
  const record = recordsByKey.get(`${item.provider}:${item.session}`);
@@ -97,7 +100,8 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
97
100
  else if (isRunning === undefined && !record.runtime.isRunning) isRunning = false;
98
101
  } catch { /* Unverified process leaves runtime status unknown. */ }
99
102
  }
100
- return { provider: item.provider, session: item.session, cwd: item.cwd, title: item.title, source: item.source,
103
+ return { provider: item.provider, session: item.session, cwd: item.cwd,
104
+ title: sessionTitle(item.title) || (item.provider === 'codex' ? codexTitles.get(item.session) || '' : ''), source: item.source,
101
105
  ...(typeof isRunning === 'boolean' ? { isRunning } : {}),
102
106
  ...(item.providerGoal !== undefined ? { providerGoal: item.providerGoal } : {}),
103
107
  ...(item.goalReadState ? { goalReadState: item.goalReadState } : {}),
@@ -0,0 +1,48 @@
1
+ import { constants } from 'node:fs';
2
+ import { lstat, open } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
7
+ const MAX_INDEX_BYTES = 1024 * 1024;
8
+ let cached;
9
+ const fingerprint = info => [info.dev, info.ino, info.size, info.mtimeNs, info.ctimeNs].join(':');
10
+
11
+ export function sessionTitle(value) {
12
+ return typeof value === 'string' ? value.replace(/[\x00-\x1f\x7f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 200) : '';
13
+ }
14
+
15
+ // Read title metadata only, including when an older CLI cannot list threads.
16
+ // This never reads prompts or changes the provider's continuation capabilities.
17
+ export async function readCodexTitles({ codexHome = process.env.CODEX_HOME || join(homedir(), '.codex') } = {}) {
18
+ const path = join(codexHome, 'session_index.jsonl');
19
+ let handle;
20
+ try {
21
+ const before = await lstat(path, { bigint: true });
22
+ if (!before.isFile() || before.isSymbolicLink()) return new Map();
23
+ const key = fingerprint(before);
24
+ if (cached?.path === path && cached.key === key) return new Map(cached.titles);
25
+ handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
26
+ if (fingerprint(await handle.stat({ bigint: true })) !== key) return new Map();
27
+ const size = Number(before.size), offset = Math.max(0, size - MAX_INDEX_BYTES);
28
+ const buffer = Buffer.alloc(Math.min(size, MAX_INDEX_BYTES));
29
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
30
+ let text = buffer.subarray(0, bytesRead).toString('utf8');
31
+ if (offset) text = text.slice(text.indexOf('\n') + 1);
32
+ text = text.slice(0, text.lastIndexOf('\n') + 1);
33
+ const titles = new Map();
34
+ for (const line of text.split('\n')) {
35
+ try {
36
+ const item = JSON.parse(line);
37
+ if (!UUID.test(item.id || '')) continue;
38
+ const title = sessionTitle(item.thread_name);
39
+ // Appended renames win, including an explicitly cleared title.
40
+ if (typeof item.thread_name === 'string') titles.set(item.id, title);
41
+ } catch { /* A broken entry does not hide other sessions. */ }
42
+ }
43
+ if (fingerprint(await handle.stat({ bigint: true })) !== key || fingerprint(await lstat(path, { bigint: true })) !== key) return new Map();
44
+ cached = { path, key, titles };
45
+ return new Map(titles);
46
+ } catch { return new Map(); }
47
+ finally { await handle?.close(); }
48
+ }
@@ -29,9 +29,18 @@ export async function claudePluginFiles() {
29
29
  delete hooks.hooks.Interrupt; // Codex-only event; Claude interruptions cannot be vetoed.
30
30
  hooks.hooks.StopFailure = structuredClone(hooks.hooks.Stop);
31
31
  hooks.hooks.Notification = structuredClone(hooks.hooks.PermissionRequest);
32
- for (const groups of Object.values(hooks.hooks)) {
32
+ for (const [event, groups] of Object.entries(hooks.hooks)) {
33
33
  for (const group of groups) {
34
34
  for (const hook of group.hooks) {
35
+ // These Claude events reuse handlers, but their review labels must describe
36
+ // the actual trigger rather than the Codex event they were copied from.
37
+ if (event === "StopFailure") {
38
+ hook.statusMessage = hook.command.includes("dev-work.mjs")
39
+ ? "SIMY development progress reporting after an error"
40
+ : "SIMY Delivery Loop error-recovery tracking";
41
+ } else if (event === "Notification") {
42
+ hook.statusMessage = "SIMY Delivery Loop permission-notification tracking";
43
+ }
35
44
  hook.command = hook.command.replace(/^node /, `${quoteRuntime(process.execPath)} `)
36
45
  .replaceAll("process.env.PLUGIN_ROOT", "process.env.CLAUDE_PLUGIN_ROOT")
37
46
  .replaceAll("hook.main('codex')", "hook.main('claude')");