@awak-app/simy-cli 0.7.12 → 0.7.14
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 +26 -0
- package/package.json +2 -2
- package/src/agent.js +3 -1
- package/src/cli-contract.js +1 -1
- package/src/console/sessions.js +58 -0
- package/src/desktop-host.js +32 -1
- package/src/index.js +5 -1
- package/src/session-supervision/remote-entry.js +20 -0
- package/src/session-supervision/remote-hosts.js +218 -0
- package/src/session-supervision/session-list.js +79 -0
package/README.md
CHANGED
|
@@ -1193,3 +1193,29 @@ Diagnostics do not reconnect providers or enable existing tasks. Explicit refres
|
|
|
1193
1193
|
waits for a fresh probe when a pre-login check is already running. macOS login
|
|
1194
1194
|
terminals preserve an explicitly configured `CODEX_HOME` or `CLAUDE_CONFIG_DIR`,
|
|
1195
1195
|
without copying credentials.
|
|
1196
|
+
|
|
1197
|
+
### Remote Codex conversations on Mac
|
|
1198
|
+
|
|
1199
|
+
Connect an existing NVIDIA/SSH machine from **Remote connections…** in the
|
|
1200
|
+
conversation sidebar. SIMY merges its conversations and sends operations to the
|
|
1201
|
+
original machine. See [setup, access requirements and recovery](docs/remote-codex-sessions.md).
|
|
1202
|
+
|
|
1203
|
+
### Local conversation list on NVIDIA / Linux
|
|
1204
|
+
|
|
1205
|
+
Run `simy sessions` directly on the machine where Codex is running. It reads
|
|
1206
|
+
that machine's actual Codex conversation metadata without starting the agent,
|
|
1207
|
+
updater, analysis worker or a Delivery Loop. No Mac or SSH bridge is needed.
|
|
1208
|
+
|
|
1209
|
+
```sh
|
|
1210
|
+
simy sessions --query NVIDIA
|
|
1211
|
+
simy sessions --interactive
|
|
1212
|
+
simy sessions --json
|
|
1213
|
+
```
|
|
1214
|
+
|
|
1215
|
+
In the interactive list, use Up/Down to select a conversation, `/` to search its
|
|
1216
|
+
title, folder or ID, Enter to finish searching, Esc to clear the filter, `r` to
|
|
1217
|
+
refresh and `q` to quit. The selected conversation shows its exact ID and folder.
|
|
1218
|
+
Search covers the current recent window (up to 200 active conversations and,
|
|
1219
|
+
with `--include-archived`, up to 200 archived conversations); older entries outside
|
|
1220
|
+
that window are not searched. This view respects the saved
|
|
1221
|
+
Codex provider connection setting and never enables automatic continuation.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awak-app/simy-cli",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.14",
|
|
4
4
|
"description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"test:e2e:console:real-provider": "node ./scripts/real-provider-console-e2e.js",
|
|
40
40
|
"test:e2e:console:fullstack": "node ./scripts/fullstack-cli-smoke.js",
|
|
41
41
|
"check": "npm run check:auto-update && node --check ./src/desktop-executor.js && node --check ./src/local-task.js && node --check ./scripts/desktop-executor-e2e.js && node --check ./scripts/pre-guardrail-e2e.js && node --check ./scripts/fixtures/fake-desktop-provider.js && npm run check:syntax && npm run check:session-supervision-syntax && npm run check:real-provider-syntax && npm test && npm run check:package",
|
|
42
|
-
"check:session-supervision-syntax": "node --check ./src/session-supervision/index.js && node --check ./src/session-supervision/discovery-window.js && node --check ./src/session-supervision/codex-app-server.js && node --check ./src/session-supervision/claude-sessions.js && node --check ./src/session-supervision/original-session-target.js && node --check ./src/session-supervision/action-association.js && node --check ./src/session-supervision/action-association-host.js && node --check ./src/session-supervision/action-association-api.js && node --check ./src/session-supervision/association-store.js && node --check ./src/session-supervision/action-deadline.js && node --check ./src/session-supervision/action-deadline-store.js && node --check ./src/codex-plugins/simy/hooks/action-association.mjs && node --check ./src/session-supervision/purpose-instruction.js && node --check ./src/session-supervision/purpose-recovery.js && node --check ./src/session-supervision/purpose-candidate.js && node --check ./src/codex-plugins/simy/hooks/scene-reasoning.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-cycle.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-rule-review.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-search.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-evidence.mjs && node --check ./src/session-supervision/purpose-observer.js && node --check ./src/session-supervision/purpose-verification.js && node --check ./src/codex-plugins/simy/hooks/purpose-workspace.mjs && node --check ./scripts/purpose-search-live-e2e.js",
|
|
42
|
+
"check:session-supervision-syntax": "node --check ./src/session-supervision/session-list.js && node --check ./src/console/sessions.js && node --check ./src/session-supervision/remote-hosts.js && node --check ./src/session-supervision/remote-entry.js && node --check ./src/session-supervision/index.js && node --check ./src/session-supervision/discovery-window.js && node --check ./src/session-supervision/codex-app-server.js && node --check ./src/session-supervision/claude-sessions.js && node --check ./src/session-supervision/original-session-target.js && node --check ./src/session-supervision/action-association.js && node --check ./src/session-supervision/action-association-host.js && node --check ./src/session-supervision/action-association-api.js && node --check ./src/session-supervision/association-store.js && node --check ./src/session-supervision/action-deadline.js && node --check ./src/session-supervision/action-deadline-store.js && node --check ./src/codex-plugins/simy/hooks/action-association.mjs && node --check ./src/session-supervision/purpose-instruction.js && node --check ./src/session-supervision/purpose-recovery.js && node --check ./src/session-supervision/purpose-candidate.js && node --check ./src/codex-plugins/simy/hooks/scene-reasoning.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-cycle.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-rule-review.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-search.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-evidence.mjs && node --check ./src/session-supervision/purpose-observer.js && node --check ./src/session-supervision/purpose-verification.js && node --check ./src/codex-plugins/simy/hooks/purpose-workspace.mjs && node --check ./scripts/purpose-search-live-e2e.js",
|
|
43
43
|
"check:auto-update": "node --check ./src/auto-update.js && node --check ./scripts/auto-update-e2e.js && npm run check:sqm",
|
|
44
44
|
"check:workflow-observation-sender": "node --check ./src/workflow-observation-sender.js",
|
|
45
45
|
"check:sqm": "node --check ./src/sqm/advisory-guidance.js && node --check ./src/sqm/canonical.js && node --check ./src/sqm/validation.js && node --check ./src/sqm/reliability.js && node --check ./src/sqm/repository.js && node --check ./src/sqm/checkers.js && node --check ./src/sqm/bundle-store.js && node --check ./src/sqm/proof.js && node --check ./src/sqm/evidence-client.js && node --check ./src/sqm/session.js && node --check ./src/sqm/index.js && node --check ./src/sqm/command.js && node --check ./src/sqm/agent-skill.js && node --check ./src/sqm/workflow-onboarding.js && node --check ./src/sqm/workflow-update.js && node --check ./src/sqm/history-store.js && node --check ./src/sqm/review-worker.js && node --check ./scripts/sqm-complete-loop-e2e.js && node --check ./scripts/sqm-local-review-e2e.js",
|
package/src/agent.js
CHANGED
|
@@ -650,7 +650,9 @@ export async function startAgent({
|
|
|
650
650
|
if (!isSessionValid(session, Date.now(), apiOrigin)) throw new Error("Sign in before switching organizations.");
|
|
651
651
|
if (organizationState !== "ready" || !organizationItems.some(org => org.id === organizationId)) throw new Error("Refresh the organization list before selecting an organization.");
|
|
652
652
|
const choice = organizationItems.find(org => org.id === organizationId);
|
|
653
|
-
|
|
653
|
+
// Selecting an authenticated membership is separate from permission to run.
|
|
654
|
+
// The new session still requires a fresh server entitlement before work.
|
|
655
|
+
if (choice.desktopAccess === "unavailable") throw new Error("Refresh organizations before switching; access could not be verified.");
|
|
654
656
|
organizationSlug = choice.slug;
|
|
655
657
|
if (!organizationSlug) throw new Error('Refresh organizations before switching; its verified slug is unavailable.');
|
|
656
658
|
authNonce = randomBytes(16).toString("base64url");
|
package/src/cli-contract.js
CHANGED
|
@@ -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.7.
|
|
16
|
+
export const CLI_VERSION = "0.7.14";
|
|
17
17
|
export const CLI_API_CONTRACT_VERSION = 10;
|
|
18
18
|
|
|
19
19
|
export function narrativeImageCapabilityMetadata() {
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { jsx, jsxs } from 'react/jsx-runtime';
|
|
3
|
+
import { Box, Text, render, useApp, useInput, useStdout } from 'ink';
|
|
4
|
+
import { filterSessionList, terminalText } from '../session-supervision/session-list.js';
|
|
5
|
+
|
|
6
|
+
export function SessionListConsole({ catalog: initial, query: initialQuery = '', refresh }) {
|
|
7
|
+
const { exit } = useApp(), { stdout } = useStdout();
|
|
8
|
+
const [catalog, setCatalog] = useState(initial), [query, setQuery] = useState(initialQuery);
|
|
9
|
+
const [searching, setSearching] = useState(false), [index, setIndex] = useState(0);
|
|
10
|
+
const [busy, setBusy] = useState(false), [notice, setNotice] = useState('');
|
|
11
|
+
const [height, setHeight] = useState(stdout.rows || 24);
|
|
12
|
+
useEffect(() => { const resize = () => setHeight(stdout.rows || 24); stdout.on('resize', resize); return () => stdout.off('resize', resize); }, [stdout]);
|
|
13
|
+
const rows = filterSessionList(catalog.sessions, query), selected = Math.max(0, Math.min(index, Math.max(0, rows.length - 1)));
|
|
14
|
+
const pageSize = Math.max(1, height - 10), offset = Math.floor(selected / pageSize) * pageSize;
|
|
15
|
+
useInput((text, key) => {
|
|
16
|
+
if (key.ctrl && text === 'c' || !searching && text === 'q') { exit(); return; }
|
|
17
|
+
if (key.escape) { setSearching(false); setQuery(''); setIndex(0); return; }
|
|
18
|
+
if (searching) {
|
|
19
|
+
if (key.return) setSearching(false);
|
|
20
|
+
else if (key.backspace || key.delete) { setQuery(q => [...q].slice(0, -1).join('')); setIndex(0); }
|
|
21
|
+
else if (text && !key.ctrl && !key.meta && !key.upArrow && !key.downArrow) { setQuery(q => q + (text === ' ' ? ' ' : terminalText(text))); setIndex(0); }
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (text === '/') { setSearching(true); return; }
|
|
25
|
+
if (key.upArrow) setIndex(Math.max(0, selected - 1));
|
|
26
|
+
if (key.downArrow) setIndex(Math.max(0, Math.min(rows.length - 1, selected + 1)));
|
|
27
|
+
if (text === 'r' && !busy) {
|
|
28
|
+
setBusy(true); setNotice('');
|
|
29
|
+
void refresh().then(next => { setCatalog(next); setIndex(0); }, error => {
|
|
30
|
+
if (['provider_link_disconnected', 'provider_link_changed'].includes(error.code)) {
|
|
31
|
+
setCatalog(current => ({ ...current, sessions: [] })); setIndex(0);
|
|
32
|
+
}
|
|
33
|
+
setNotice(terminalText(error.message));
|
|
34
|
+
}).finally(() => setBusy(false));
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
const current = rows[selected];
|
|
38
|
+
return jsxs(Box, { flexDirection: 'column', children: [
|
|
39
|
+
jsx(Text, { bold: true, color: 'cyan', children: `SIMY · Codex会話一覧 · ${terminalText(catalog.host)}` }),
|
|
40
|
+
jsx(Text, { children: `表示 ${rows.length} / ${catalog.sessions.length} 件 · この端末の実会話` }),
|
|
41
|
+
jsx(Text, { color: searching ? 'yellow' : undefined, children: `検索: ${query}${searching ? '▏' : ''}` }),
|
|
42
|
+
...rows.slice(offset, offset + pageSize).map((row, i) => jsx(Text, {
|
|
43
|
+
color: offset + i === selected ? 'green' : undefined, wrap: 'truncate-end',
|
|
44
|
+
children: `${offset + i === selected ? '›' : ' '} ${terminalText(row.title)}`,
|
|
45
|
+
}, row.session)),
|
|
46
|
+
...(!rows.length ? [jsx(Text, { children: '一致する会話はありません。' }, 'empty')] : []),
|
|
47
|
+
jsx(Text, { dimColor: true, children: current ? `${terminalText(current.workState?.label)} · ${current.session}` : '' }),
|
|
48
|
+
jsx(Text, { dimColor: true, wrap: 'truncate-end', children: terminalText(current?.cwd) }),
|
|
49
|
+
jsx(Text, { color: notice ? 'red' : undefined, children: notice || (busy ? '更新中…' : '↑↓ 選択 / 検索 Esc 解除 r 更新 q 終了') }),
|
|
50
|
+
] });
|
|
51
|
+
}
|
|
52
|
+
export async function runSessionListConsole({ input, output, signal, ...props }) {
|
|
53
|
+
const app = render(jsx(SessionListConsole, props), { stdin: input, stdout: output, exitOnCtrlC: false, patchConsole: false });
|
|
54
|
+
const abort = () => app.unmount();
|
|
55
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
56
|
+
if (signal?.aborted) abort();
|
|
57
|
+
try { await app.waitUntilExit(); } finally { signal?.removeEventListener('abort', abort); app.unmount(); }
|
|
58
|
+
}
|
package/src/desktop-host.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createRemoteHostManager } from './session-supervision/remote-hosts.js';
|
|
1
2
|
import { createUpdateRuntime } from './update-detachment/runtime.js';
|
|
2
3
|
import { createCoordinatorReconciler } from "./update-detachment/reconciliation-adapter.js";
|
|
3
4
|
import { createLedgerCoordinatorAdapters } from "./update-detachment/ledger-adapter.js";
|
|
@@ -75,6 +76,7 @@ async function withReadCancellation(signal, work) {
|
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
function desktopActionError(action, error) {
|
|
79
|
+
if (String(error?.code).startsWith("remote_host_")) return error.message;
|
|
78
80
|
if (error?.code === "provider_link_changed") return "SIMY connection changed. Refresh and select the conversation again.";
|
|
79
81
|
if (error?.code === "provider_link_disconnected") return "Reconnect this provider to SIMY before using it.";
|
|
80
82
|
if (["provider_connect", "provider_disconnect"].includes(action)) {
|
|
@@ -163,7 +165,13 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
163
165
|
// volatile; reuse the encoded body after comparing freshly authorized data.
|
|
164
166
|
// Detach the comparison value because registries may mutate nested objects.
|
|
165
167
|
let encodedStatus = null, lastStatus = null, updateProgress = null;
|
|
168
|
+
let remoteHosts;
|
|
166
169
|
const emitStatus = status => {
|
|
170
|
+
if (env.SIMY_REMOTE_CHILD === "1") status = { ...status, remoteSessionProtocol: 1 };
|
|
171
|
+
if (remoteHosts) {
|
|
172
|
+
void remoteHosts.update(providerLinks.codex.connected ? status : { ...status, access: { state: "unavailable" } }).catch(() => {});
|
|
173
|
+
if (providerLinks.codex.connected) status = remoteHosts.project(status);
|
|
174
|
+
}
|
|
167
175
|
if (updateProgress) status = { ...status, update: { ...status.update, ...updateProgress } };
|
|
168
176
|
const { sampledAt, ...content } = status;
|
|
169
177
|
if (!lastStatus || !isDeepStrictEqual(content, lastStatus)) {
|
|
@@ -212,6 +220,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
212
220
|
let activeStatisticsRead = null;
|
|
213
221
|
let statisticsSelection = { key: null, generation: 0 };
|
|
214
222
|
const stopNotifier = createStopNotifier({ emit: event => { if (!closed && !relay) emit(event); } });
|
|
223
|
+
if (env.SIMY_REMOTE_CHILD !== "1" && process.platform !== "win32") remoteHosts = (dependencies.createRemoteHostManager || createRemoteHostManager)({ root, onChange: () => { if (!closed && !relay) setImmediate(() => { void publish().catch(() => {}); }); } });
|
|
215
224
|
let currentAccess = { state: 'checking', organizationId: null, organizationName: null };
|
|
216
225
|
let nativeDevelopmentReports = { state: 'loading', enrollments: [] };
|
|
217
226
|
let nativeDevelopmentReportsScope = null;
|
|
@@ -785,7 +794,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
785
794
|
// reader must never postpone cancellation of an owned Provider process.
|
|
786
795
|
const results = await Promise.allSettled([stopDiscovery({ drain: true }), publishing, connectionPublishing,
|
|
787
796
|
pluginInspection,
|
|
788
|
-
actionAssociations.close(), automaticContinuation?.close(), analysisManager?.close(),
|
|
797
|
+
remoteHosts?.close(), actionAssociations.close(), automaticContinuation?.close(), analysisManager?.close(),
|
|
789
798
|
managedSupervisor.close(), recordReader.close(), stopWorkflows(),
|
|
790
799
|
agent ? createAgentShutdown(agent)() : undefined]);
|
|
791
800
|
const failure = results.find(result => result.status === "rejected");
|
|
@@ -1108,6 +1117,27 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
1108
1117
|
!(action === "session_set_auto_continue" && command.enabled === false)) {
|
|
1109
1118
|
commandProviderLink = await requireProviderLink({ root: deliveryRoot, provider: command.provider });
|
|
1110
1119
|
}
|
|
1120
|
+
if (command.hostId && command.hostId !== 'local') {
|
|
1121
|
+
if (!remoteHosts || command.provider !== 'codex') throw Object.assign(new Error('Remote host is unavailable.'), { code: 'remote_host_unavailable' });
|
|
1122
|
+
await authorizeProvider(command.provider, commandScope, commandProviderLink?.epoch);
|
|
1123
|
+
const status = await agent.getDesktopStatus();
|
|
1124
|
+
// Status collection can yield while provider consent is revoked.
|
|
1125
|
+
// Reject before writing SSH bytes, not only after its result arrives.
|
|
1126
|
+
await authorizeProvider(command.provider, commandScope, commandProviderLink?.epoch);
|
|
1127
|
+
const result = await remoteHosts.forward(command, status);
|
|
1128
|
+
await authorizeProvider(command.provider, commandScope, commandProviderLink?.epoch);
|
|
1129
|
+
emit(result); void publish().catch(() => {}); return;
|
|
1130
|
+
}
|
|
1131
|
+
if (!command.hostId && command.session && remoteHosts?.hasSession(command.session)) {
|
|
1132
|
+
throw Object.assign(new Error('Select the host for this conversation before continuing.'), { code: 'remote_host_invalid' });
|
|
1133
|
+
}
|
|
1134
|
+
if (action === 'remote_host_save' || action === 'remote_host_remove') {
|
|
1135
|
+
if (!remoteHosts) throw Object.assign(new Error('Nested remote connections are unavailable.'), { code: 'remote_host_invalid' });
|
|
1136
|
+
if (action === 'remote_host_save') await authorizeProvider('codex', commandScope);
|
|
1137
|
+
else await authorizeWork(commandScope);
|
|
1138
|
+
await remoteHosts.save({ alias: command.alias, label: command.label }, action === 'remote_host_remove');
|
|
1139
|
+
emit({ type: 'result', id: command.id, ok: true }); await publish(); return;
|
|
1140
|
+
}
|
|
1111
1141
|
if (action === "provider_connect" || action === "provider_disconnect") {
|
|
1112
1142
|
await changeProviderLink(command.provider, action === "provider_connect");
|
|
1113
1143
|
emit({ type: "result", id: command.id, ok: true });
|
|
@@ -1594,6 +1624,7 @@ function desktopStartupError(error, fallbackMessage = "SIMY could not start. Che
|
|
|
1594
1624
|
|
|
1595
1625
|
// Immutable pins predate this option; their launcher must keep the old update fence.
|
|
1596
1626
|
export const supportsDesktopUpdatePackageRoot = true;
|
|
1627
|
+
export const remoteSessionProtocol = 1;
|
|
1597
1628
|
|
|
1598
1629
|
export function startDesktopEntry(options = {}) {
|
|
1599
1630
|
let host;
|
package/src/index.js
CHANGED
|
@@ -38,7 +38,7 @@ const commandArgv = argv.filter((value) => value !== "--no-auto-update");
|
|
|
38
38
|
// Delivery receives the resolved origin separately; global --host is not one
|
|
39
39
|
// of its command-specific options, regardless of its position on the CLI.
|
|
40
40
|
const deliveryArgv = commandArgv.filter((value, index) => value !== "--host" && commandArgv[index - 1] !== "--host");
|
|
41
|
-
const dispatchArgv = deliveryArgv[0]
|
|
41
|
+
const dispatchArgv = ["delivery", "sessions"].includes(deliveryArgv[0]) ? deliveryArgv : commandArgv;
|
|
42
42
|
const entryPath = fileURLToPath(import.meta.url);
|
|
43
43
|
const packageInfo = await readCliPackageInfo();
|
|
44
44
|
const updatesDisabled = args.has("--no-auto-update") || process.env.SIMY_AUTO_UPDATE === "0";
|
|
@@ -62,6 +62,9 @@ if (!configuredWebOrigin) {
|
|
|
62
62
|
// Invalid host input is reported above before update discovery or startup.
|
|
63
63
|
} else if (args.has("--version") || args.has("-v")) {
|
|
64
64
|
console.log(packageInfo.version);
|
|
65
|
+
} else if (dispatchArgv[0] === "sessions") {
|
|
66
|
+
const { runSessionListCommand } = await import("./session-supervision/session-list.js");
|
|
67
|
+
process.exitCode = await runSessionListCommand(dispatchArgv.slice(1));
|
|
65
68
|
} else if (dispatchArgv[0] === "doctor") {
|
|
66
69
|
const { runDoctor } = await import("./doctor.js");
|
|
67
70
|
process.exitCode = await runDoctor({ webOrigin: configuredWebOrigin, cliVersion: packageInfo.version });
|
|
@@ -146,6 +149,7 @@ async function runCommand(installMode) {
|
|
|
146
149
|
simy sqm lint Validate module reliability before publication
|
|
147
150
|
simy sqm replay Replay modules against labelled historical snapshots
|
|
148
151
|
simy update --check Check the installed and latest stable CLI versions
|
|
152
|
+
simy sessions Show local Codex conversations (--interactive for search/navigation)
|
|
149
153
|
simy update ${installMode === "macos_installer" ? "Show Apple Silicon update status and installer action" : "Install an eligible stable CLI update"}
|
|
150
154
|
|
|
151
155
|
Options:
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// SSH-only entry point. Keep the normal signed/runtime-pinned desktop startup,
|
|
2
|
+
// account/organization checks, provider consent and native execution boundaries.
|
|
3
|
+
// No device credential is relayed from the Mac and hosts cannot recurse.
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { pathToFileURL } from 'node:url';
|
|
7
|
+
import { adoptInstalledRuntime, pinnedWorkflowSource } from '../delivery-hooks/local-runtime.js';
|
|
8
|
+
process.env.SIMY_REMOTE_CHILD = '1';
|
|
9
|
+
process.env.SIMY_AUTO_UPDATE = '0';
|
|
10
|
+
const root = process.env.SIMY_HOME?.trim() || join(homedir(), '.simy');
|
|
11
|
+
try {
|
|
12
|
+
await adoptInstalledRuntime({ root });
|
|
13
|
+
const selected = await pinnedWorkflowSource({ root });
|
|
14
|
+
const host = await import(selected ? pathToFileURL(join(selected, 'src/desktop-host.js')).href : '../desktop-host.js');
|
|
15
|
+
if (host.remoteSessionProtocol !== 1) throw new Error('Update the remote SIMY runtime.');
|
|
16
|
+
host.startDesktopEntry();
|
|
17
|
+
} catch {
|
|
18
|
+
process.stdout.write(JSON.stringify({ type: 'error', message: 'Remote SIMY could not start. Update its runtime and check its existing desktop connection.' }) + '\n');
|
|
19
|
+
process.exitCode = 1;
|
|
20
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { compareSessionWork } from './work-state.js';
|
|
6
|
+
|
|
7
|
+
const MAX_FRAME = 8 * 1024 * 1024;
|
|
8
|
+
const MAX_HOSTS = 8;
|
|
9
|
+
const aliasPattern = /^[a-zA-Z0-9][a-zA-Z0-9._@-]{0,127}$/;
|
|
10
|
+
export const remoteHostId = alias => `remote-ssh-discovered:${alias}`;
|
|
11
|
+
// Resolve the installed SIMY executable on the remote login PATH. No local
|
|
12
|
+
// credential, command payload, or user-supplied shell text enters this script.
|
|
13
|
+
const shellQuote = value => "'" + value.replaceAll("'", "'\\''") + "'";
|
|
14
|
+
const bootstrap = 'import { realpathSync } from "node:fs"; import { dirname, join } from "node:path"; import { pathToFileURL } from "node:url"; const entry = join(dirname(realpathSync(process.argv[1])), "session-supervision", "remote-entry.js"); await import(pathToFileURL(entry).href);';
|
|
15
|
+
export const REMOTE_HOST_COMMAND = `exec sh -lc ${shellQuote(`exec env SIMY_REMOTE_CHILD=1 SIMY_AUTO_UPDATE=0 node --input-type=module -e ${shellQuote(bootstrap)} "$(command -v simy)"`)}`;
|
|
16
|
+
const allowedActions = new Set(['refresh', 'session_statistics', 'session_archive', 'session_restore', 'session_track', 'session_untrack',
|
|
17
|
+
'session_monitor', 'session_unmonitor', 'session_enroll', 'session_set_auto_continue', 'session_analyze', 'session_continue', 'session_answer',
|
|
18
|
+
'session_deadline_decide', 'session_action_refresh', 'session_action_select', 'session_action_unlink',
|
|
19
|
+
'delivery_pause', 'delivery_stop', 'delivery_reject', 'delivery_resume', 'delivery_replan', 'delivery_approve',
|
|
20
|
+
'delivery_dependency_ready', 'delivery_set_policy', 'delivery_continuation_policy', 'delivery_verify']);
|
|
21
|
+
const error = (message, code = 'remote_host_unavailable') => Object.assign(new Error(message), { code });
|
|
22
|
+
function normalizedHost(value) {
|
|
23
|
+
if (!value || typeof value.alias !== 'string' || !aliasPattern.test(value.alias)) throw error('Enter an SSH host alias, for example nvidia-workstation.', 'remote_host_invalid');
|
|
24
|
+
const label = typeof value.label === 'string' ? value.label.trim() : '';
|
|
25
|
+
if (label.length > 80 || /[\x00-\x1f\x7f]/.test(label)) throw error('Choose a shorter host name.', 'remote_host_invalid');
|
|
26
|
+
return { alias: value.alias, label: label || value.alias, id: remoteHostId(value.alias) };
|
|
27
|
+
}
|
|
28
|
+
async function readHosts(root) {
|
|
29
|
+
let value;
|
|
30
|
+
try { value = JSON.parse(await readFile(join(root, 'remote-hosts.json'), 'utf8')); }
|
|
31
|
+
catch (e) { if (e.code === 'ENOENT') return []; throw error('Remote host settings could not be read.', 'remote_host_settings'); }
|
|
32
|
+
if (value?.schemaVersion !== 1 || !Array.isArray(value.hosts) || value.hosts.length > MAX_HOSTS) throw error('Remote host settings are invalid.', 'remote_host_settings');
|
|
33
|
+
const hosts = value.hosts.map(normalizedHost);
|
|
34
|
+
if (new Set(hosts.map(h => h.id)).size !== hosts.length) throw error('Duplicate remote hosts.', 'remote_host_settings');
|
|
35
|
+
return hosts;
|
|
36
|
+
}
|
|
37
|
+
const scopeOf = status => JSON.stringify([status?.auth?.userId, status?.access?.organizationId, status?.access?.revision]);
|
|
38
|
+
const usable = status => status?.auth?.state === 'signed_in' && typeof status.auth.userId === 'string' && status.auth.userId &&
|
|
39
|
+
status.access?.state === 'ready' && typeof status.access.organizationId === 'string' && status.access.organizationId;
|
|
40
|
+
function stamp(value, host, depth = 0) {
|
|
41
|
+
if (depth > 40) return null;
|
|
42
|
+
if (Array.isArray(value)) return value.map(item => stamp(item, host, depth + 1));
|
|
43
|
+
if (!value || typeof value !== 'object') return value;
|
|
44
|
+
const result = Object.fromEntries(Object.entries(value).map(([key, item]) => [key, stamp(item, host, depth + 1)]));
|
|
45
|
+
if (result.provider === 'codex' && typeof result.session === 'string') result.hostId = host.id;
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Each remote host owns its records, controller, provider calls and workspace.
|
|
50
|
+
// The Mac only projects status and forwards explicit, host-scoped commands.
|
|
51
|
+
export function createRemoteHostManager({ root, spawnImpl = spawn, onChange = () => {}, now = Date.now,
|
|
52
|
+
retryMs = 15_000, requestTimeoutMs = 45_000, handshakeTimeoutMs = 15_000 } = {}) {
|
|
53
|
+
const connections = new Map();
|
|
54
|
+
let scope = null, local = null, closed = false, settingsError = null, writes = Promise.resolve(), generation = 0;
|
|
55
|
+
const changed = () => { if (!closed) onChange(); };
|
|
56
|
+
function disconnect(connection, reason = 'Remote connection closed.') {
|
|
57
|
+
const child = connection.child;
|
|
58
|
+
connection.child = null; connection.ready = false; connection.reason = reason; connection.state = 'unavailable'; connection.retryAt = now() + retryMs;
|
|
59
|
+
clearTimeout(connection.handshake);
|
|
60
|
+
for (const job of connection.pending.values()) { clearTimeout(job.timer); job.reject(error('Remote connection lost. Check the original conversation before retrying this operation.')); }
|
|
61
|
+
connection.pending.clear();
|
|
62
|
+
if (child) {
|
|
63
|
+
child.stdin.end(); child.kill('SIGTERM');
|
|
64
|
+
const force = setTimeout(() => { if (child.exitCode === null && child.signalCode == null) child.kill('SIGKILL'); }, 2000);
|
|
65
|
+
force.unref?.(); child.once('close', () => clearTimeout(force));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function connect(connection) {
|
|
69
|
+
if (closed || connection.child || now() < connection.retryAt) return;
|
|
70
|
+
connection.state = 'connecting'; connection.reason = null;
|
|
71
|
+
let child;
|
|
72
|
+
try { child = spawnImpl('ssh', ['-T', '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes', '-o', 'ConnectTimeout=5',
|
|
73
|
+
'-o', 'ServerAliveInterval=10', '-o', 'ServerAliveCountMax=2', '--', connection.alias, REMOTE_HOST_COMMAND], { stdio: ['pipe', 'pipe', 'pipe'], shell: false }); }
|
|
74
|
+
catch { disconnect(connection, 'Could not start SSH.'); return; }
|
|
75
|
+
connection.child = child;
|
|
76
|
+
connection.handshake = setTimeout(() => {
|
|
77
|
+
if (connection.child === child) { disconnect(connection, 'Remote SIMY did not respond. Check SSH and update SIMY on the remote host.'); changed(); }
|
|
78
|
+
}, handshakeTimeoutMs);
|
|
79
|
+
connection.handshake.unref?.();
|
|
80
|
+
let buffer = Buffer.alloc(0);
|
|
81
|
+
const failed = () => {
|
|
82
|
+
if (connection.child !== child) return;
|
|
83
|
+
disconnect(connection, 'SSH disconnected. Check the saved host alias, SSH key, known host and remote SIMY installation.'); changed();
|
|
84
|
+
};
|
|
85
|
+
child.stderr.resume();
|
|
86
|
+
child.on('error', failed); child.on('close', failed); child.stdin.on('error', failed);
|
|
87
|
+
child.stdout.on('data', chunk => {
|
|
88
|
+
if (connection.child !== child) return;
|
|
89
|
+
let offset = 0;
|
|
90
|
+
while (offset < chunk.length) {
|
|
91
|
+
const newline = chunk.indexOf(10, offset), end = newline < 0 ? chunk.length : newline;
|
|
92
|
+
const piece = chunk.subarray(offset, end);
|
|
93
|
+
if (buffer.length + piece.length > MAX_FRAME) { disconnect(connection, 'Remote status exceeded the supported size.'); changed(); return; }
|
|
94
|
+
buffer = buffer.length ? Buffer.concat([buffer, piece]) : piece;
|
|
95
|
+
if (newline < 0) break;
|
|
96
|
+
const line = buffer.toString('utf8'); buffer = Buffer.alloc(0); offset = newline + 1;
|
|
97
|
+
let frame; try { frame = JSON.parse(line); } catch { continue; }
|
|
98
|
+
if (!frame || typeof frame !== 'object') continue;
|
|
99
|
+
if (frame.type === 'status') {
|
|
100
|
+
const status = frame.status;
|
|
101
|
+
if (status?.remoteSessionProtocol !== 1) { disconnect(connection, 'Update SIMY on the remote host to use remote sessions.'); changed(); return; }
|
|
102
|
+
if (!usable(status) || status.auth.userId !== local?.auth?.userId || status.access.organizationId !== local?.access?.organizationId) {
|
|
103
|
+
clearTimeout(connection.handshake);
|
|
104
|
+
connection.ready = false; connection.state = 'access_required'; connection.reason = 'Sign in to the same SIMY account and organization on the remote host.';
|
|
105
|
+
connection.status = null;
|
|
106
|
+
for (const job of connection.pending.values()) { clearTimeout(job.timer); job.reject(error(connection.reason, 'remote_host_access')); }
|
|
107
|
+
connection.pending.clear();
|
|
108
|
+
} else if (!Array.isArray(status.sessionSupervision?.sessions) || !Array.isArray(status.deliveryHooks?.runs)) {
|
|
109
|
+
connection.ready = false; connection.state = 'connecting'; connection.reason = 'Waiting for remote conversation status.';
|
|
110
|
+
} else {
|
|
111
|
+
clearTimeout(connection.handshake);
|
|
112
|
+
connection.status = status; connection.ready = true; connection.state = 'ready'; connection.reason = null; connection.receivedAt = now();
|
|
113
|
+
}
|
|
114
|
+
changed();
|
|
115
|
+
} else if (frame.type === 'error') {
|
|
116
|
+
disconnect(connection, 'Remote SIMY could not start. Check its installation, login and existing desktop connection.'); changed(); return;
|
|
117
|
+
} else if (frame.type === 'result') {
|
|
118
|
+
const pending = connection.pending.get(frame.id);
|
|
119
|
+
if (!pending) continue;
|
|
120
|
+
connection.pending.delete(frame.id); clearTimeout(pending.timer);
|
|
121
|
+
pending.resolve({ type: 'result', id: pending.id, ok: frame.ok === true,
|
|
122
|
+
...(frame.ok !== true ? { message: 'Remote operation could not finish. Check the original conversation and refresh before retrying.' } : {}) });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
async function update(status) {
|
|
128
|
+
if (closed) return;
|
|
129
|
+
const nextScope = usable(status) ? scopeOf(status) : null;
|
|
130
|
+
if (scope !== nextScope) {
|
|
131
|
+
generation++;
|
|
132
|
+
for (const c of connections.values()) disconnect(c);
|
|
133
|
+
connections.clear(); scope = nextScope;
|
|
134
|
+
}
|
|
135
|
+
local = { auth: status?.auth, access: status?.access };
|
|
136
|
+
if (!scope) return;
|
|
137
|
+
const observedGeneration = generation;
|
|
138
|
+
let hosts;
|
|
139
|
+
try { hosts = await readHosts(root); settingsError = null; }
|
|
140
|
+
catch (e) { settingsError = e.message; hosts = []; }
|
|
141
|
+
if (closed || scope !== nextScope || generation !== observedGeneration) return;
|
|
142
|
+
for (const [id, c] of connections) if (!hosts.some(h => h.id === id)) { disconnect(c); connections.delete(id); }
|
|
143
|
+
for (const host of hosts) {
|
|
144
|
+
let c = connections.get(host.id);
|
|
145
|
+
if (!c) { c = { ...host, pending: new Map(), retryAt: 0, ready: false, status: null }; connections.set(host.id, c); }
|
|
146
|
+
c.label = host.label;
|
|
147
|
+
if (c.ready && now() - c.receivedAt > 45_000) disconnect(c, 'Remote status is stale. Reconnecting.');
|
|
148
|
+
connect(c);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
async function save(value, remove = false) {
|
|
152
|
+
const host = normalizedHost(value);
|
|
153
|
+
const work = writes.catch(() => {}).then(async () => {
|
|
154
|
+
const hosts = (await readHosts(root)).filter(h => h.id !== host.id);
|
|
155
|
+
if (!remove) hosts.push(host);
|
|
156
|
+
if (hosts.length > MAX_HOSTS) throw error('At most eight remote hosts can be connected.', 'remote_host_invalid');
|
|
157
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
158
|
+
const path = join(root, 'remote-hosts.json'), temporary = `${path}.${randomUUID()}.tmp`;
|
|
159
|
+
try { await writeFile(temporary, JSON.stringify({ schemaVersion: 1, hosts }) + '\n', { mode: 0o600 }); await rename(temporary, path); }
|
|
160
|
+
finally { await rm(temporary, { force: true }); }
|
|
161
|
+
generation++;
|
|
162
|
+
const c = connections.get(host.id);
|
|
163
|
+
if (c) { disconnect(c); if (remove) connections.delete(host.id); else c.retryAt = 0; }
|
|
164
|
+
changed();
|
|
165
|
+
});
|
|
166
|
+
writes = work; await work;
|
|
167
|
+
}
|
|
168
|
+
async function forward(command, status) {
|
|
169
|
+
if (closed || !usable(status) || scopeOf(status) !== scope) throw error('SIMY account changed. Refresh before using the remote host.', 'remote_host_access');
|
|
170
|
+
const c = connections.get(command.hostId);
|
|
171
|
+
if (!c?.ready || !c.child || now() - c.receivedAt > 45_000) throw error('The remote host is disconnected. Reconnect before using this conversation.');
|
|
172
|
+
if (!allowedActions.has(command.action) || command.provider !== 'codex' || typeof command.session !== 'string' ||
|
|
173
|
+
!c.status?.sessionSupervision?.sessions?.some(s => s?.provider === 'codex' && s.session === command.session)) throw error('Select an available remote Codex conversation.', 'remote_host_invalid');
|
|
174
|
+
const id = randomUUID();
|
|
175
|
+
const { hostId, ...payload } = command;
|
|
176
|
+
return new Promise((resolve, reject) => {
|
|
177
|
+
const timer = setTimeout(() => { c.pending.delete(id); reject(error('Remote operation timed out. Check its result in the original conversation before retrying.')); }, requestTimeoutMs);
|
|
178
|
+
c.pending.set(id, { id: command.id, resolve, reject, timer });
|
|
179
|
+
c.child.stdin.write(JSON.stringify({ ...payload, id }) + '\n', e => {
|
|
180
|
+
if (e && c.pending.has(id)) { c.pending.delete(id); clearTimeout(timer); reject(error('Remote command could not be delivered.')); }
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
function project(status) {
|
|
185
|
+
if (!usable(status) || scopeOf(status) !== scope) return status;
|
|
186
|
+
const hosts = [...connections.values()];
|
|
187
|
+
const sessions = [], runs = [];
|
|
188
|
+
for (const c of hosts) {
|
|
189
|
+
const ready = c.ready && now() - c.receivedAt <= 45_000;
|
|
190
|
+
const unavailableReason = c.reason || 'Remote status is stale.';
|
|
191
|
+
for (const row of c.status?.sessionSupervision?.sessions || []) {
|
|
192
|
+
if (!row || row.provider !== 'codex' || typeof row.session !== 'string') continue;
|
|
193
|
+
const item = stamp(row, c);
|
|
194
|
+
item.remoteHost = { id: c.id, label: c.label, state: ready ? 'ready' : c.state === 'ready' ? 'unavailable' : c.state, reason: ready ? null : c.reason || 'Remote status is stale.' };
|
|
195
|
+
// Codex's host-specific deep link contract is unverified. Preserve exact
|
|
196
|
+
// navigation context instead of silently opening a cloned local UUID.
|
|
197
|
+
item.originalSessionTarget = { kind: 'manual', url: null, label: 'Find in remote Codex', verifiedOnPlatform: false,
|
|
198
|
+
reason: `Select ${c.label} in Codex, then find conversation ${row.session}.` };
|
|
199
|
+
if (!ready) {
|
|
200
|
+
item.capabilities = Object.fromEntries(Object.keys(item.capabilities || {}).map(key => [key, false]));
|
|
201
|
+
item.autoContinueAvailability = { canEnable: false, reasonCode: 'remote_host_unavailable', reason: unavailableReason };
|
|
202
|
+
item.autoContinueStatus = 'unavailable'; item.autoContinueReason = unavailableReason;
|
|
203
|
+
item.isRunning = false; item.availability = 'unknown'; item.reason = unavailableReason; item.decisionRequest = null;
|
|
204
|
+
item.workState = { state: 'unknown', label: '接続先を確認できません', reason: unavailableReason, stale: true, fetchFailed: true, checkedAt: c.receivedAt, needsAttention: false };
|
|
205
|
+
item.statistics = { state: 'unavailable', hours: [], activity: [] };
|
|
206
|
+
}
|
|
207
|
+
sessions.push(item);
|
|
208
|
+
}
|
|
209
|
+
if (ready) for (const run of c.status?.deliveryHooks?.runs || []) if (run?.provider === 'codex') runs.push({ ...stamp(run, c), originalSessionTarget: { kind: 'manual', url: null, verifiedOnPlatform: false, reason: `Select ${c.label} in Codex, then find conversation ${run.session}.` } });
|
|
210
|
+
}
|
|
211
|
+
return { ...status, remoteHosts: { error: settingsError, items: hosts.map(c => ({ id: c.id, alias: c.alias, label: c.label, state: c.state, reason: c.reason })) },
|
|
212
|
+
sessionSupervision: { ...status.sessionSupervision, sessions: [...(status.sessionSupervision?.sessions || []).filter(row => !row.hostId || row.hostId === 'local'), ...sessions].sort(compareSessionWork) },
|
|
213
|
+
deliveryHooks: { ...status.deliveryHooks, runs: [...(status.deliveryHooks?.runs || []).filter(row => !row.hostId || row.hostId === 'local'), ...runs] } };
|
|
214
|
+
}
|
|
215
|
+
return { update, save, forward, project,
|
|
216
|
+
hasSession: session => [...connections.values()].some(c => c.status?.sessionSupervision?.sessions?.some(s => s?.provider === 'codex' && s.session === session)),
|
|
217
|
+
async close() { closed = true; for (const c of connections.values()) disconnect(c); connections.clear(); await writes.catch(() => {}); } };
|
|
218
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { hostname } from 'node:os';
|
|
2
|
+
import { stripVTControlCharacters } from 'node:util';
|
|
3
|
+
import { readProviderLinks } from '../provider-links.js';
|
|
4
|
+
import { openCodexAppServer, listCodexSessions } from './codex-app-server.js';
|
|
5
|
+
import { readCodexTitles } from './session-titles.js';
|
|
6
|
+
import { sessionWorkState } from './work-state.js';
|
|
7
|
+
|
|
8
|
+
export const terminalText = value => stripVTControlCharacters(String(value ?? '')).replace(/[\x00-\x1f\x7f-\x9f]/g, ' ').trim();
|
|
9
|
+
export function filterSessionList(sessions, query = '') {
|
|
10
|
+
const needle = query.trim().toLocaleLowerCase();
|
|
11
|
+
return sessions.filter(row => !needle || [row.title, row.session, row.cwd].some(value => String(value || '').toLocaleLowerCase().includes(needle)));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// List directly from the installed local provider. No agent, supervisor,
|
|
15
|
+
// 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
|
+
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();
|
|
27
|
+
signal?.throwIfAborted();
|
|
28
|
+
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
|
+
})) };
|
|
35
|
+
}
|
|
36
|
+
export function formatSessionList(catalog, query = '') {
|
|
37
|
+
const rows = filterSessionList(catalog.sessions, query);
|
|
38
|
+
return [`SIMY · Codex会話一覧 · ${terminalText(catalog.host)}`,
|
|
39
|
+
`表示 ${rows.length} / ${catalog.sessions.length} 件${query ? ` · 検索: ${terminalText(query)}` : ''}`,
|
|
40
|
+
...rows.flatMap((row, index) => [
|
|
41
|
+
`${index + 1}. ${terminalText(row.title)}`,
|
|
42
|
+
` ${terminalText(row.workState?.label || '状態未確認')} · ${row.session}`,
|
|
43
|
+
` ${terminalText(row.cwd)}`,
|
|
44
|
+
]), ...(rows.length ? [] : ['一致する会話はありません。'])].join('\n') + '\n';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
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;
|
|
50
|
+
try {
|
|
51
|
+
for (let i = 0; i < argv.length; i++) {
|
|
52
|
+
const arg = argv[i];
|
|
53
|
+
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;
|
|
55
|
+
}
|
|
56
|
+
if (arg === '--query') {
|
|
57
|
+
if (typeof argv[i + 1] !== 'string' || argv[i + 1].startsWith('--')) throw new Error('--query requires search text.');
|
|
58
|
+
query = argv[++i];
|
|
59
|
+
} else if (arg === '--json') json = true;
|
|
60
|
+
else if (arg === '--interactive') interactive = true;
|
|
61
|
+
else if (arg === '--include-archived') includeArchived = true;
|
|
62
|
+
else throw new Error(`Unknown sessions option: ${terminalText(arg)}`);
|
|
63
|
+
}
|
|
64
|
+
if (json && interactive) throw new Error('Choose --json or --interactive.');
|
|
65
|
+
if (interactive && !input.isTTY) throw new Error('--interactive requires a terminal. Use simy sessions for plain output.');
|
|
66
|
+
const controller = new AbortController();
|
|
67
|
+
const cancel = () => controller.abort();
|
|
68
|
+
process.once('SIGINT', cancel); process.once('SIGTERM', cancel);
|
|
69
|
+
try {
|
|
70
|
+
const refresh = () => load({ includeArchived, signal: controller.signal });
|
|
71
|
+
const catalog = await refresh();
|
|
72
|
+
if (interactive) {
|
|
73
|
+
const start = renderInteractive || (await import('../console/sessions.js')).runSessionListConsole;
|
|
74
|
+
await start({ catalog, query, refresh, input, output, signal: controller.signal });
|
|
75
|
+
} else output.write(json ? JSON.stringify({ ...catalog, sessions: filterSessionList(catalog.sessions, query) }, null, 2) + '\n' : formatSessionList(catalog, query));
|
|
76
|
+
} finally { controller.abort(); process.removeListener('SIGINT', cancel); process.removeListener('SIGTERM', cancel); }
|
|
77
|
+
return 0;
|
|
78
|
+
} catch (error) { errors.write(`SIMY: ${terminalText(error.message)}\n`); return 1; }
|
|
79
|
+
}
|