@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.
@@ -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, readLinks = readProviderLinks, readTitles = readCodexTitles } = {}) {
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
- if (!links.codex.connected) throw Object.assign(new Error('Codex is disconnected from SIMY. Reconnect it before listing conversations.'), { code: 'provider_link_disconnected' });
20
- const client = await openClient({ signal, cancelOnAbort: true });
21
- let sessions;
22
- try {
23
- sessions = await listCodexSessions({ client, inspectState: true });
24
- if (includeArchived) sessions.push(...(await listCodexSessions({ client, archived: true })).map(row => ({ ...row, archived: true })));
25
- } finally { await client.close(); }
26
- const titles = await readTitles();
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
- if (!currentLinks.codex.connected || currentLinks.codex.epoch !== links.codex.epoch) throw Object.assign(new Error('Codex connection changed. Refresh the conversation list.'), { code: 'provider_link_changed' });
30
- return { host: hostname(), observedAt: new Date().toISOString(), sessions: [...new Map(sessions.map(row => [row.session, row])).values()].map(row => ({
31
- provider: 'codex', session: row.session, title: terminalText(row.title || titles.get(row.session) || row.session),
32
- cwd: terminalText(row.cwd), archived: row.archived === true,
33
- workState: sessionWorkState(row),
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 = readLocalSessionList, renderInteractive } = {}) {
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 local Codex conversations. No task is started or resumed.\n'); return 0;
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
  },