@awak-app/simy-cli 0.7.19 → 0.7.21
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/README.md +67 -18
- package/package.json +1 -1
- package/src/auto-update.js +17 -1
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +9 -1
- package/src/codex-work-pattern-sync.js +243 -43
- package/src/console/sessions.js +6 -6
- package/src/delivery-hooks/command.js +8 -6
- package/src/delivery-hooks/lifecycle-recovery.js +82 -0
- package/src/delivery-hooks/local-supervisor.js +25 -2
- package/src/delivery-hooks/supervisor-activation.js +25 -0
- package/src/delivery-hooks/supervisor-service.js +2 -2
- package/src/delivery-hooks/supervisor-watchdog.js +11 -6
- package/src/desktop-access.js +1 -1
- package/src/desktop-host.js +40 -11
- package/src/desktop-update-settings.js +15 -0
- package/src/index.js +1 -1
- package/src/session-supervision/claude-sessions.js +7 -7
- package/src/session-supervision/codex-app-server.js +19 -9
- package/src/session-supervision/codex-peer-opinion.js +128 -0
- package/src/session-supervision/index.js +21 -12
- package/src/session-supervision/remote-control.js +27 -0
- package/src/session-supervision/remote-errors.js +22 -0
- package/src/session-supervision/remote-hosts.js +17 -13
- package/src/session-supervision/remote-session-list.js +56 -0
- package/src/session-supervision/session-list.js +83 -23
- package/src/update-detachment/runtime.js +8 -4
|
@@ -1,57 +1,112 @@
|
|
|
1
|
+
import { readRemoteSessionLists } from './remote-session-list.js';
|
|
1
2
|
import { hostname } from 'node:os';
|
|
2
3
|
import { stripVTControlCharacters } from 'node:util';
|
|
3
4
|
import { readProviderLinks } from '../provider-links.js';
|
|
4
5
|
import { openCodexAppServer, listCodexSessions } from './codex-app-server.js';
|
|
6
|
+
import { listClaudeSessions } from './claude-sessions.js';
|
|
5
7
|
import { readCodexTitles } from './session-titles.js';
|
|
6
8
|
import { sessionWorkState } from './work-state.js';
|
|
7
9
|
|
|
8
10
|
export const terminalText = value => stripVTControlCharacters(String(value ?? '')).replace(/[\x00-\x1f\x7f-\x9f]/g, ' ').trim();
|
|
9
11
|
export function filterSessionList(sessions, query = '') {
|
|
10
12
|
const needle = query.trim().toLocaleLowerCase();
|
|
11
|
-
return sessions.filter(row => !needle || [row.title, row.session, row.cwd].some(value => String(value || '').toLocaleLowerCase().includes(needle)));
|
|
13
|
+
return sessions.filter(row => !needle || [row.title, row.session, row.cwd, row.provider, row.hostLabel].some(value => String(value || '').toLocaleLowerCase().includes(needle)));
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
// List directly from the installed local provider. No agent, supervisor,
|
|
15
17
|
// enrollment, archive reconciliation or provider turn is started by this view.
|
|
16
|
-
export async function readLocalSessionList({ root, signal, includeArchived = false,
|
|
17
|
-
openClient = openCodexAppServer,
|
|
18
|
+
export async function readLocalSessionList({ root, signal, includeArchived = false, provider = 'all',
|
|
19
|
+
openClient = openCodexAppServer, listClaude = listClaudeSessions,
|
|
20
|
+
readLinks = readProviderLinks, readTitles = readCodexTitles } = {}) {
|
|
18
21
|
const links = await readLinks(root);
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
22
|
+
const providers = ['codex', 'claude'].filter(name => links[name]?.connected && (provider === 'all' || name === provider));
|
|
23
|
+
if (!providers.length) throw Object.assign(new Error('Codex and Claude Code are disconnected from SIMY. Reconnect a provider before listing conversations.'), { code: 'provider_link_disconnected' });
|
|
24
|
+
const results = await Promise.allSettled(providers.map(async provider => {
|
|
25
|
+
signal?.throwIfAborted();
|
|
26
|
+
if (provider === 'claude') return (await listClaude({ all: true, signal })).filter(row => includeArchived || !row.archived);
|
|
27
|
+
const client = await openClient({ signal, cancelOnAbort: true });
|
|
28
|
+
let sessions;
|
|
29
|
+
try {
|
|
30
|
+
sessions = await listCodexSessions({ client, all: true, signal, inspectState: true });
|
|
31
|
+
if (includeArchived) sessions.push(...(await listCodexSessions({ client, all: true, signal, archived: true })).map(row => ({ ...row, archived: true })));
|
|
32
|
+
} finally { await client.close(); }
|
|
33
|
+
const titles = await readTitles();
|
|
34
|
+
return sessions.map(row => ({ ...row, title: row.title || titles.get(row.session) }));
|
|
35
|
+
}));
|
|
27
36
|
signal?.throwIfAborted();
|
|
28
37
|
const currentLinks = await readLinks(root);
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
38
|
+
for (const provider of providers) {
|
|
39
|
+
if (!currentLinks[provider]?.connected || currentLinks[provider].epoch !== links[provider].epoch)
|
|
40
|
+
throw Object.assign(new Error('Provider connection changed. Refresh the conversation list.'), { code: 'provider_link_changed' });
|
|
41
|
+
}
|
|
42
|
+
if (results.every(result => result.status === 'rejected')) throw results[0].reason;
|
|
43
|
+
const warnings = results.flatMap((result, index) => result.status === 'rejected'
|
|
44
|
+
? [`${providers[index]}: ${terminalText(result.reason?.message || 'Conversation discovery unavailable')}`] : []);
|
|
45
|
+
const sessions = results.flatMap(result => result.status === 'fulfilled' ? result.value : []);
|
|
46
|
+
return { host: hostname(), observedAt: new Date().toISOString(), complete: warnings.length === 0, warnings,
|
|
47
|
+
sessions: [...new Map(sessions.map(row => [`${row.provider}:${row.session}`, row])).values()]
|
|
48
|
+
.sort((a, b) => (b.lastActivityAt || 0) - (a.lastActivityAt || 0) || a.provider.localeCompare(b.provider) || a.session.localeCompare(b.session))
|
|
49
|
+
.map(row => ({
|
|
50
|
+
provider: row.provider, session: row.session, title: terminalText(row.title || row.session),
|
|
51
|
+
cwd: terminalText(row.cwd), archived: row.archived === true,
|
|
52
|
+
workState: sessionWorkState(row),
|
|
53
|
+
})) };
|
|
35
54
|
}
|
|
55
|
+
// Aggregate only explicitly registered hosts, preserving the host/provider/ID tuple.
|
|
56
|
+
export async function readSessionList({ root, signal, provider = 'all', localOnly = false, includeArchived = false,
|
|
57
|
+
readLinks = readProviderLinks, loadLocal = readLocalSessionList, loadRemote = readRemoteSessionLists } = {}) {
|
|
58
|
+
const links = await readLinks(root);
|
|
59
|
+
const providers = ['codex', 'claude'].filter(name => links[name]?.connected && (provider === 'all' || name === provider));
|
|
60
|
+
if (!providers.length) throw Object.assign(new Error('Reconnect a provider before listing conversations.'), { code: 'provider_link_disconnected' });
|
|
61
|
+
const [local, remote] = await Promise.allSettled([
|
|
62
|
+
loadLocal({ root, signal, provider, includeArchived, readLinks }),
|
|
63
|
+
localOnly ? [] : loadRemote({ root, signal, providers, includeArchived }),
|
|
64
|
+
]);
|
|
65
|
+
signal?.throwIfAborted();
|
|
66
|
+
const current = await readLinks(root);
|
|
67
|
+
for (const name of providers) if (!current[name]?.connected || current[name].epoch !== links[name].epoch)
|
|
68
|
+
throw Object.assign(new Error('Provider connection changed. Refresh the conversation list.'), { code: 'provider_link_changed' });
|
|
69
|
+
const warnings = [], sessions = [], hosts = [];
|
|
70
|
+
if (local.status === 'fulfilled') {
|
|
71
|
+
sessions.push(...local.value.sessions.map(row => ({ ...row, hostId: 'local', hostLabel: local.value.host })));
|
|
72
|
+
warnings.push(...(local.value.warnings || []));
|
|
73
|
+
} else warnings.push(`local: ${terminalText(local.reason?.message)}`);
|
|
74
|
+
if (remote.status === 'rejected') warnings.push(`remote: ${terminalText(remote.reason?.message)}`);
|
|
75
|
+
else for (const result of remote.value) {
|
|
76
|
+
hosts.push({ id: result.host.id, label: result.host.label, state: result.error ? 'unavailable' : 'ready' });
|
|
77
|
+
if (result.error) { warnings.push(`${result.host.label}: ${terminalText(result.error)}`); continue; }
|
|
78
|
+
warnings.push(...(result.catalog.warnings || []).map(warning => `${result.host.label}: ${terminalText(warning)}`));
|
|
79
|
+
if (result.catalog.complete === false && !result.catalog.warnings?.length) warnings.push(`${result.host.label}: incomplete catalog`);
|
|
80
|
+
sessions.push(...result.catalog.sessions.map(row => ({
|
|
81
|
+
provider: row.provider, session: row.session, title: terminalText(row.title || row.session), cwd: terminalText(row.cwd),
|
|
82
|
+
archived: row.archived === true, hostId: result.host.id, hostLabel: result.host.label,
|
|
83
|
+
workState: { label: terminalText(row.workState?.label || '状態未確認') },
|
|
84
|
+
})));
|
|
85
|
+
}
|
|
86
|
+
return { host: hostname(), observedAt: new Date().toISOString(), complete: warnings.length === 0, warnings, hosts,
|
|
87
|
+
sessions: [...new Map(sessions.map(row => [`${row.hostId}:${row.provider}:${row.session}`, row])).values()] };
|
|
88
|
+
}
|
|
89
|
+
|
|
36
90
|
export function formatSessionList(catalog, query = '') {
|
|
37
91
|
const rows = filterSessionList(catalog.sessions, query);
|
|
38
|
-
return [`SIMY · Codex会話一覧 · ${terminalText(catalog.host)}`,
|
|
92
|
+
return [`SIMY · Codex / Claude Code 会話一覧 · ${terminalText(catalog.host)}`,
|
|
39
93
|
`表示 ${rows.length} / ${catalog.sessions.length} 件${query ? ` · 検索: ${terminalText(query)}` : ''}`,
|
|
94
|
+
...(catalog.warnings || []).map(warning => `取得未完了: ${terminalText(warning)}`),
|
|
40
95
|
...rows.flatMap((row, index) => [
|
|
41
96
|
`${index + 1}. ${terminalText(row.title)}`,
|
|
42
|
-
` ${terminalText(row.workState?.label || '状態未確認')} · ${row.session}`,
|
|
97
|
+
` ${terminalText(row.hostLabel || catalog.host)} · ${row.provider === 'claude' ? 'Claude Code' : 'Codex'} · ${terminalText(row.workState?.label || '状態未確認')} · ${row.session}`,
|
|
43
98
|
` ${terminalText(row.cwd)}`,
|
|
44
99
|
]), ...(rows.length ? [] : ['一致する会話はありません。'])].join('\n') + '\n';
|
|
45
100
|
}
|
|
46
101
|
|
|
47
102
|
export async function runSessionListCommand(argv, { output = process.stdout, errors = process.stderr, input = process.stdin,
|
|
48
|
-
load =
|
|
49
|
-
let query = '', json = false, interactive = false, includeArchived = false;
|
|
103
|
+
load = readSessionList, renderInteractive } = {}) {
|
|
104
|
+
let query = '', json = false, interactive = false, includeArchived = false, localOnly = false, provider = 'all';
|
|
50
105
|
try {
|
|
51
106
|
for (let i = 0; i < argv.length; i++) {
|
|
52
107
|
const arg = argv[i];
|
|
53
108
|
if (arg === '--help' || arg === '-h') {
|
|
54
|
-
output.write('simy sessions [--query TEXT] [--json | --interactive] [--include-archived]\n\nShow
|
|
109
|
+
output.write('simy sessions [--query TEXT] [--json | --interactive] [--include-archived] [--local-only] [--provider all|codex|claude]\n\nShow available Codex and Claude Code conversations on this host and registered SSH hosts. No task is started or resumed.\n'); return 0;
|
|
55
110
|
}
|
|
56
111
|
if (arg === '--query') {
|
|
57
112
|
if (typeof argv[i + 1] !== 'string' || argv[i + 1].startsWith('--')) throw new Error('--query requires search text.');
|
|
@@ -59,6 +114,11 @@ export async function runSessionListCommand(argv, { output = process.stdout, err
|
|
|
59
114
|
} else if (arg === '--json') json = true;
|
|
60
115
|
else if (arg === '--interactive') interactive = true;
|
|
61
116
|
else if (arg === '--include-archived') includeArchived = true;
|
|
117
|
+
else if (arg === '--local-only') localOnly = true;
|
|
118
|
+
else if (arg === '--provider') {
|
|
119
|
+
provider = argv[++i];
|
|
120
|
+
if (!['all', 'codex', 'claude'].includes(provider)) throw new Error('--provider requires all, codex or claude.');
|
|
121
|
+
}
|
|
62
122
|
else throw new Error(`Unknown sessions option: ${terminalText(arg)}`);
|
|
63
123
|
}
|
|
64
124
|
if (json && interactive) throw new Error('Choose --json or --interactive.');
|
|
@@ -67,7 +127,7 @@ export async function runSessionListCommand(argv, { output = process.stdout, err
|
|
|
67
127
|
const cancel = () => controller.abort();
|
|
68
128
|
process.once('SIGINT', cancel); process.once('SIGTERM', cancel);
|
|
69
129
|
try {
|
|
70
|
-
const refresh = () => load({ includeArchived, signal: controller.signal });
|
|
130
|
+
const refresh = () => load({ includeArchived, localOnly, provider, signal: controller.signal });
|
|
71
131
|
const catalog = await refresh();
|
|
72
132
|
if (interactive) {
|
|
73
133
|
const start = renderInteractive || (await import('../console/sessions.js')).runSessionListConsole;
|
|
@@ -259,18 +259,22 @@ export function createUpdateRuntime({ root, origin, readContext, listRecords, ob
|
|
|
259
259
|
};
|
|
260
260
|
return {bind,boundary,reconcile,store,gate:adapters.gate,
|
|
261
261
|
async initialize() {if(!transaction)write({connectionState:readDispatchGate(root).connection,phase:'startup',updateState:'pending'});},
|
|
262
|
-
snapshot(base,update) {
|
|
262
|
+
snapshot(base,update,{accessReady=true}={}) {
|
|
263
263
|
if(ownerTransferred)transaction=readState(file);
|
|
264
264
|
const action=base.state==='connected' && update?.state==='waiting_for_idle'?'detach_update':base.state==='detaching'?'cancel_detach_update':!base.busy && ['detached','recovery_required'].includes(base.state)?'update_reconnect':null;
|
|
265
265
|
const frame={...base,schemaVersion:1,connectionState:base.state,
|
|
266
|
-
updateState:transaction?.updateState || 'pending',tasks:rows(),actions:action?[action]:[]};
|
|
266
|
+
updateState:transaction?.updateState || 'pending',updaterState:update?.state || 'unknown',tasks:rows(),actions:action?[action]:[]};
|
|
267
|
+
// Revision the exact frame the native client sees. Filtering after the
|
|
268
|
+
// fingerprint makes an access change look like conflicting data at the
|
|
269
|
+
// same revision, which correctly fences all native update actions.
|
|
270
|
+
if(!accessReady) {frame.tasks=[];frame.actions=frame.actions.filter(action=>action==='cancel_detach_update');}
|
|
267
271
|
const fingerprint=digest(frame);
|
|
268
272
|
if(!ownerTransferred && transaction && transaction.presentation!==fingerprint)write({presentation:fingerprint});
|
|
269
273
|
presented={...frame,operationId:transaction?.operationId,revision:transaction?.revision || 0};
|
|
270
274
|
return presented;
|
|
271
275
|
},
|
|
272
|
-
validate(command,base,update) {
|
|
273
|
-
const frame=this.snapshot(base,update);
|
|
276
|
+
validate(command,base,update,options) {
|
|
277
|
+
const frame=this.snapshot(base,update,options);
|
|
274
278
|
if(command.operationId!==frame.operationId || command.revision!==frame.revision || !frame.actions.includes(command.action) || transaction?.claimedCommands?.includes(command.id))throw failure('stale_operation');
|
|
275
279
|
write({claimedCommands:[...(transaction?.claimedCommands || []).slice(-255),command.id]});
|
|
276
280
|
},
|