@awak-app/simy-cli 0.7.29 → 0.7.32
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 +88 -3
- package/package.json +2 -2
- package/src/agent.js +8 -2
- package/src/cli-contract.js +1 -1
- package/src/cli-observability.js +1 -1
- package/src/codex-plugins/simy/.codex-plugin/plugin.json +5 -3
- package/src/codex-plugins/simy/hooks/acceptance-policy.mjs +7 -0
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +389 -51
- package/src/codex-plugins/simy/hooks/purpose-evidence.mjs +6 -3
- package/src/codex-plugins/simy/hooks/update-dispatch-gate.mjs +126 -4
- package/src/codex-plugins/simy/skills/continue/SKILL.md +69 -0
- package/src/codex-plugins/simy/skills/continue/agents/openai.yaml +6 -0
- package/src/delivery-hooks/claude-workflow.md +3 -3
- package/src/delivery-hooks/command.js +8 -0
- package/src/delivery-hooks/desktop.js +31 -9
- package/src/delivery-hooks/supervisor.js +3 -2
- package/src/desktop-access.js +4 -1
- package/src/desktop-host.js +494 -72
- package/src/native-development-report-completion.js +26 -0
- package/src/native-development-reports.js +15 -5
- package/src/orchestrator/completion-contract.js +8 -2
- package/src/orchestrator/contract.js +3 -1
- package/src/orchestrator/instruction.js +1 -1
- package/src/runner.js +2 -1
- package/src/session-supervision/action-deadline.js +18 -1
- package/src/session-supervision/autonomous-upgrade.js +4 -2
- package/src/session-supervision/claude-desktop-archive.js +57 -0
- package/src/session-supervision/claude-desktop-native.js +50 -0
- package/src/session-supervision/claude-desktop-native.jxa.js +195 -0
- package/src/session-supervision/claude-desktop-target.js +87 -0
- package/src/session-supervision/claude-owner-renewal.js +197 -0
- package/src/session-supervision/claude-sessions.js +143 -34
- package/src/session-supervision/codex-app-server.js +20 -2
- package/src/session-supervision/continuation-analysis.js +7 -7
- package/src/session-supervision/continuation-command.js +45 -0
- package/src/session-supervision/controller-store.js +48 -0
- package/src/session-supervision/conversation-presentation.js +241 -0
- package/src/session-supervision/decision-presentation.js +96 -35
- package/src/session-supervision/execution-context.js +39 -0
- package/src/session-supervision/index.js +36 -7
- package/src/session-supervision/manual-acceptance-command.js +30 -0
- package/src/session-supervision/notification-authority.js +9 -0
- package/src/session-supervision/notification-journal.js +40 -0
- package/src/session-supervision/operation-journal.js +42 -0
- package/src/session-supervision/owner-transport.js +146 -0
- package/src/session-supervision/purpose-recovery.js +10 -2
- package/src/session-supervision/remote-control.js +1 -1
- package/src/session-supervision/remote-entry.js +2 -0
- package/src/session-supervision/remote-errors.js +15 -0
- package/src/session-supervision/remote-hosts.js +344 -36
- package/src/session-supervision/remote-metadata.js +103 -0
- package/src/session-supervision/remote-session-list.js +2 -4
- package/src/session-supervision/session-archive-command.js +45 -0
- package/src/session-supervision/session-list.js +40 -4
- package/src/session-supervision/sqm-presentation.js +46 -3
- package/src/session-supervision/stop-notifications.js +42 -11
- package/src/session-supervision/work-state.js +106 -6
- package/src/sqm/agent-skill.js +3 -3
- package/src/sqm/claude-plugin.js +5 -2
- package/src/sqm/history-store.js +6 -0
- package/src/team-threads.js +4 -7
- package/src/update-detachment/coordinator.js +7 -1
- package/src/update-detachment/history-recovery.js +18 -0
- package/src/update-detachment/ledger-adapter.js +1 -0
- package/src/update-detachment/retained-paused.js +290 -0
- package/src/update-detachment/runtime.js +78 -15
- package/src/web-api.js +9 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { controllerExecutionContext } from './execution-context.js';
|
|
2
|
+
import { readRecord, updateRecord } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { createActionAssociationHost } from './action-association-host.js';
|
|
6
|
+
import { createActionDeadlineService } from './action-deadline.js';
|
|
7
|
+
|
|
8
|
+
// Web metadata belongs to the controller. Separate roots prevent a cloned
|
|
9
|
+
// provider UUID on another host from reusing a local binding or deadline.
|
|
10
|
+
export function createRemoteMetadata({ root, webOrigin, onChange, createAssociations = createActionAssociationHost, createDeadlines = createActionDeadlineService }) {
|
|
11
|
+
const hosts = new Map();
|
|
12
|
+
function host(scope, hostId) {
|
|
13
|
+
const key = JSON.stringify([scope, hostId]);
|
|
14
|
+
if (!hosts.has(key)) {
|
|
15
|
+
const directory = join(root, 'controller-metadata', createHash('sha256').update(key).digest('hex'));
|
|
16
|
+
hosts.set(key, { recordsRoot: join(directory, 'records'), associations: createAssociations({ root: join(directory, 'records'), sessionRoot: root, webOrigin, onChange }),
|
|
17
|
+
deadlines: createDeadlines({ root: join(directory, 'deadlines') }), candidates: new Map(), active: new Set() });
|
|
18
|
+
}
|
|
19
|
+
return hosts.get(key);
|
|
20
|
+
}
|
|
21
|
+
async function decorate(scope, rows) {
|
|
22
|
+
const groups = new Map();
|
|
23
|
+
for (const row of rows) { if (!groups.has(row.hostId)) groups.set(row.hostId, []); groups.get(row.hostId).push(row); }
|
|
24
|
+
for (const [id, group] of groups) {
|
|
25
|
+
const h = host(scope, id);
|
|
26
|
+
// These are controller metadata records, never enrollments or executable
|
|
27
|
+
// remote runs. Keep the original execution task identity separate.
|
|
28
|
+
for (const row of group) {
|
|
29
|
+
const sourceTaskId = row.sourceActionTaskId;
|
|
30
|
+
if (!row.cwd || !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(sourceTaskId || '')) continue;
|
|
31
|
+
await updateRecord(h.recordsRoot, row.provider, row.session, previous => previous || ({
|
|
32
|
+
schemaVersion: 1, provider: row.provider, session: row.session, cwd: row.cwd, run: null,
|
|
33
|
+
associationTaskId: sourceTaskId, controllerMigration: { sourceHostId: id, sourceTaskId },
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
const candidates = group.map(row => ({ ...row, executionTaskId: row.binding?.mode === 'managed' ? row.binding.taskId || row.taskId : null, binding: { mode: 'unmanaged' } }));
|
|
37
|
+
await h.associations.decorateSessions(candidates);
|
|
38
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
39
|
+
const candidate = candidates[i];
|
|
40
|
+
const context = await h.associations.deadlineContext(candidate);
|
|
41
|
+
if (context) {
|
|
42
|
+
if (group[i].sourceActionDeadline && h.deadlines.importSnapshot) await h.deadlines.importSnapshot(context, group[i].sourceActionDeadline);
|
|
43
|
+
candidate.actionDeadline = await h.deadlines.reconcile(context, { displayOnly: true });
|
|
44
|
+
}
|
|
45
|
+
h.candidates.set(`${candidate.provider}:${candidate.session}`, candidate);
|
|
46
|
+
Object.assign(group[i], { actionAssociation: candidate.actionAssociation, actionDeadline: candidate.actionDeadline, actionTaskId: candidate.taskId });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return rows;
|
|
50
|
+
}
|
|
51
|
+
async function syncPending(scope, enqueue) {
|
|
52
|
+
for (const [key, h] of hosts) {
|
|
53
|
+
const [account, hostId] = JSON.parse(key);
|
|
54
|
+
if (account !== scope) continue;
|
|
55
|
+
for (const [sessionKey, candidate] of h.candidates) {
|
|
56
|
+
if (h.active.has(sessionKey)) continue;
|
|
57
|
+
h.active.add(sessionKey);
|
|
58
|
+
try {
|
|
59
|
+
const record = await readRecord(h.recordsRoot, candidate.provider, candidate.session);
|
|
60
|
+
const intent = record?.controllerContextPending;
|
|
61
|
+
if (!intent) continue;
|
|
62
|
+
await enqueue({ action: 'session_sync_context', hostId, provider: candidate.provider, session: candidate.session,
|
|
63
|
+
requestId: intent, context: controllerExecutionContext(candidate) });
|
|
64
|
+
await updateRecord(h.recordsRoot, candidate.provider, candidate.session, current => {
|
|
65
|
+
if (current?.controllerContextPending === intent) delete current.controllerContextPending;
|
|
66
|
+
return current;
|
|
67
|
+
});
|
|
68
|
+
} finally { h.active.delete(sessionKey); }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { decorate, syncPending,
|
|
73
|
+
async command(scope, command) {
|
|
74
|
+
const h = host(scope, command.hostId);
|
|
75
|
+
const candidate = h.candidates.get(`${command.provider}:${command.session}`);
|
|
76
|
+
if (!candidate) throw new Error('Refresh the conversation metadata.');
|
|
77
|
+
if (command.action === 'session_deadline_decide' && command.cancel === true) return;
|
|
78
|
+
const sessionKey = `${command.provider}:${command.session}`;
|
|
79
|
+
if (h.active.has(sessionKey)) throw new Error('Conversation metadata is updating; retry shortly.');
|
|
80
|
+
h.active.add(sessionKey);
|
|
81
|
+
try {
|
|
82
|
+
if (command.action !== 'session_action_refresh') await updateRecord(h.recordsRoot, command.provider, command.session, previous => ({
|
|
83
|
+
...(previous || { schemaVersion: 1, provider: command.provider, session: command.session, cwd: candidate.cwd, run: null }),
|
|
84
|
+
controllerContextPending: randomUUID(),
|
|
85
|
+
}));
|
|
86
|
+
if (command.action === 'session_deadline_decide') {
|
|
87
|
+
if (command.cancel === true) return;
|
|
88
|
+
const context = await h.associations.deadlineContext(candidate);
|
|
89
|
+
if (!context || context.taskId !== command.taskId) throw new Error('Deadline context changed.');
|
|
90
|
+
await h.deadlines.reconcile(context);
|
|
91
|
+
const result = await h.deadlines.decide(context, { ...command, requestId: command.requestId || command.decisionId });
|
|
92
|
+
if (result.status === 'conflict') throw Object.assign(new Error('Deadline changed.'), { code: 'conflict' });
|
|
93
|
+
} else await h.associations.correct(command, candidate);
|
|
94
|
+
await h.associations.decorateSessions([candidate]);
|
|
95
|
+
const updatedContext = await h.associations.deadlineContext(candidate);
|
|
96
|
+
if (updatedContext) candidate.actionDeadline = await h.deadlines.reconcile(updatedContext, { displayOnly: true });
|
|
97
|
+
onChange?.();
|
|
98
|
+
return controllerExecutionContext(candidate);
|
|
99
|
+
} finally { h.active.delete(sessionKey); }
|
|
100
|
+
},
|
|
101
|
+
async close() { await Promise.all([...hosts.values()].map(h => h.associations.close())); hosts.clear(); },
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import { readHosts } from './remote-hosts.js';
|
|
4
|
+
import { readHosts, remoteSshArgs } from './remote-hosts.js';
|
|
5
5
|
|
|
6
6
|
// This read-only command never boots a remote desktop host or supervisor.
|
|
7
7
|
export async function readRemoteSessionLists({ root = process.env.SIMY_HOME?.trim() || join(homedir(), '.simy'),
|
|
@@ -14,9 +14,7 @@ export async function readRemoteSessionLists({ root = process.env.SIMY_HOME?.tri
|
|
|
14
14
|
if (!['all', 'codex', 'claude'].includes(provider)) throw new Error('No connected provider.');
|
|
15
15
|
const command = `exec sh -lc 'exec simy sessions --json --local-only --provider ${provider}${includeArchived ? ' --include-archived' : ''}'`;
|
|
16
16
|
const catalog = await new Promise((resolve, reject) => {
|
|
17
|
-
const child = spawnImpl('ssh',
|
|
18
|
-
'-o', 'ConnectTimeout=5', '-o', 'ServerAliveInterval=10', '-o', 'ServerAliveCountMax=2',
|
|
19
|
-
'--', host.alias, command], { stdio: ['ignore', 'pipe', 'pipe'], shell: false });
|
|
17
|
+
const child = spawnImpl('ssh', remoteSshArgs(host.alias, command), { stdio: ['ignore', 'pipe', 'pipe'], shell: false });
|
|
20
18
|
let chunks = [], size = 0, done = false;
|
|
21
19
|
const finish = (error, value) => {
|
|
22
20
|
if (done) return; done = true;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { requestOwnerArchive } from './owner-transport.js';
|
|
5
|
+
import { terminalText } from './session-list.js';
|
|
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
|
+
export async function runSessionArchiveCommand(argv, { output = process.stdout, errors = process.stderr,
|
|
8
|
+
root = process.env.SIMY_HOME?.trim() || join(homedir(), '.simy'), request = requestOwnerArchive } = {}) {
|
|
9
|
+
const operation = argv[0];
|
|
10
|
+
const options = { hostId: 'local' };
|
|
11
|
+
try {
|
|
12
|
+
if (!['archive', 'restore', 'provider-status', 'provider-setup'].includes(operation)) throw new Error('Unknown conversation operation.');
|
|
13
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
14
|
+
output.write('simy sessions archive|restore --provider claude|codex --session UUID [--host local|SSH_ALIAS] [--request-id ID]\nsimy sessions provider-status|provider-setup --provider claude [--host SSH_ALIAS --session UUID]\n\nArchive and restore affect the original provider conversation. SIMY must be running and connected to the provider. The graphical UI is unchanged. Results are JSON; verified means provider state was read back. A remote setup opens settings on that host.\n');
|
|
15
|
+
return 0;
|
|
16
|
+
}
|
|
17
|
+
const fields = new Map([['--provider', 'provider'], ['--session', 'session'], ['--host', 'hostId'], ['--request-id', 'requestId']]);
|
|
18
|
+
const seen = new Set();
|
|
19
|
+
for (let index = 1; index < argv.length; index++) {
|
|
20
|
+
const name = fields.get(argv[index]);
|
|
21
|
+
if (!name || seen.has(name) || !argv[index + 1] || argv[index + 1].startsWith('--')) throw new Error('Use explicit --provider, --session and --host options. See --help.');
|
|
22
|
+
seen.add(name); options[name] = argv[++index];
|
|
23
|
+
}
|
|
24
|
+
const setup = operation.startsWith('provider-');
|
|
25
|
+
if (!['claude', 'codex'].includes(options.provider) || setup && options.provider !== 'claude') throw new Error('Select a supported provider explicitly.');
|
|
26
|
+
if ((!setup || options.hostId !== 'local' || options.session !== undefined) && !UUID.test(options.session || '')) throw new Error('Select the exact conversation UUID using simy sessions --include-archived --json.');
|
|
27
|
+
if (options.hostId !== 'local') {
|
|
28
|
+
const alias = options.hostId.replace(/^remote-ssh-discovered:/, '');
|
|
29
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._@-]{0,127}$/.test(alias)) throw new Error('Use a registered SSH alias.');
|
|
30
|
+
options.hostId = 'remote-ssh-discovered:' + alias;
|
|
31
|
+
}
|
|
32
|
+
if (options.requestId !== undefined && !/^[a-zA-Z0-9_-]{1,128}$/.test(options.requestId)) throw new Error('Invalid request ID.');
|
|
33
|
+
const command = { id: randomUUID(), ...options,
|
|
34
|
+
action: setup ? 'session_provider_setup' : 'session_' + operation,
|
|
35
|
+
...(setup ? { operation: operation === 'provider-status' ? 'inspect' : 'setup' } : { scope: 'provider', requestId: options.requestId || randomUUID() }) };
|
|
36
|
+
const controller = new AbortController(), cancel = () => controller.abort();
|
|
37
|
+
process.once('SIGINT', cancel); process.once('SIGTERM', cancel);
|
|
38
|
+
let result;
|
|
39
|
+
try { result = await request({ root, command, signal: controller.signal }); }
|
|
40
|
+
finally { process.removeListener('SIGINT', cancel); process.removeListener('SIGTERM', cancel); }
|
|
41
|
+
output.write(JSON.stringify({ ...result, requestId: command.requestId, hostId: command.hostId, provider: command.provider, session: command.session }) + '\n');
|
|
42
|
+
if (!result?.ok) return result?.code === 'remote_operation_unknown' ? 2 : 1;
|
|
43
|
+
return setup && result.data?.available !== true ? 2 : 0;
|
|
44
|
+
} catch (error) { errors.write('SIMY: ' + terminalText(error?.message || 'Archive result unavailable.') + '\n'); return 1; }
|
|
45
|
+
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
import { createControllerStore, controllerScope } from './controller-store.js';
|
|
2
|
+
import { readSession, isSessionValid } from '../session-store.js';
|
|
3
|
+
import { resolveWebOrigin } from '../web-origin.js';
|
|
1
4
|
import { readRemoteSessionLists } from './remote-session-list.js';
|
|
2
|
-
import { hostname } from 'node:os';
|
|
5
|
+
import { hostname, homedir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
3
7
|
import { stripVTControlCharacters } from 'node:util';
|
|
4
8
|
import { readProviderLinks } from '../provider-links.js';
|
|
5
9
|
import { openCodexAppServer, listCodexSessions } from './codex-app-server.js';
|
|
@@ -23,7 +27,7 @@ export async function readLocalSessionList({ root, signal, includeArchived = fal
|
|
|
23
27
|
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
28
|
const results = await Promise.allSettled(providers.map(async provider => {
|
|
25
29
|
signal?.throwIfAborted();
|
|
26
|
-
if (provider === 'claude') return (await listClaude({ all: true, signal })).filter(row => includeArchived || !row.archived);
|
|
30
|
+
if (provider === 'claude') return (await listClaude({ all: true, signal })).filter(row => includeArchived || !row.archived && !row.localHidden);
|
|
27
31
|
const client = await openClient({ signal, cancelOnAbort: true });
|
|
28
32
|
let sessions;
|
|
29
33
|
try {
|
|
@@ -53,11 +57,33 @@ export async function readLocalSessionList({ root, signal, includeArchived = fal
|
|
|
53
57
|
})) };
|
|
54
58
|
}
|
|
55
59
|
// Aggregate only explicitly registered hosts, preserving the host/provider/ID tuple.
|
|
56
|
-
export async function readSessionList({ root, signal, provider = 'all', localOnly = false, includeArchived = false,
|
|
60
|
+
export async function readSessionList({ root = process.env.SIMY_HOME?.trim() || join(homedir(), '.simy'), signal, provider = 'all', localOnly = false, includeArchived = false,
|
|
57
61
|
readLinks = readProviderLinks, loadLocal = readLocalSessionList, loadRemote = readRemoteSessionLists } = {}) {
|
|
58
62
|
const links = await readLinks(root);
|
|
59
63
|
const providers = ['codex', 'claude'].filter(name => links[name]?.connected && (provider === 'all' || name === provider));
|
|
60
64
|
if (!providers.length) throw Object.assign(new Error('Reconnect a provider before listing conversations.'), { code: 'provider_link_disconnected' });
|
|
65
|
+
const webOrigin = resolveWebOrigin(null, process.env);
|
|
66
|
+
const login = await readSession(webOrigin, root);
|
|
67
|
+
const scope = isSessionValid(login, Date.now(), webOrigin) ? controllerScope({ auth: { userId: login.account_id ?? login.auth_user_id }, access: { organizationId: login.organization_id } }) : null;
|
|
68
|
+
const store = createControllerStore(root);
|
|
69
|
+
const controller = scope ? await store.load(scope) : null;
|
|
70
|
+
const cache = controller?.catalog;
|
|
71
|
+
const cacheRows = (cache?.sessions || []).filter(row => providers.includes(row.provider) && (!localOnly || !row.hostId || row.hostId === 'local') && cache.providerLinks?.[row.provider]?.epoch === links[row.provider]?.epoch);
|
|
72
|
+
const decorate = rows => rows.map(row => {
|
|
73
|
+
const preference = scope ? store.get(scope, row) : {};
|
|
74
|
+
return { ...row, deleted: preference.deleted === true, ...(preference.pinned === undefined ? {} : { listPinned: preference.pinned }),
|
|
75
|
+
...(preference.hidden === undefined ? {} : { localHidden: preference.hidden }),
|
|
76
|
+
...(preference.statistics ? { statistics: { ...preference.statistics, ...(row.workState?.stale ? { stale: true, cached: true } : {}) } } : {}),
|
|
77
|
+
...(preference.policy ? { controllerPolicy: preference.policy } : {}) };
|
|
78
|
+
}).filter(row => !row.deleted && (includeArchived || !row.archived && !row.localHidden));
|
|
79
|
+
if (cache && Number.isFinite(cache.observedAt) && cache.observedAt <= Date.now() && Date.now() - cache.observedAt <= 45_000) {
|
|
80
|
+
const current = await readLinks(root);
|
|
81
|
+
if (providers.some(name => !current[name]?.connected || current[name].epoch !== links[name].epoch)) throw Object.assign(new Error('Provider connection changed.'), { code: 'provider_link_changed' });
|
|
82
|
+
signal?.throwIfAborted();
|
|
83
|
+
const hosts = localOnly ? [] : cache.hosts || [];
|
|
84
|
+
const warnings = hosts.filter(host => host.state !== 'ready').map(host => `${terminalText(host.label || host.id)}: ${terminalText(host.reason || 'unavailable')}`);
|
|
85
|
+
return { host: hostname(), observedAt: new Date(cache.observedAt).toISOString(), complete: warnings.length === 0, warnings, hosts, sessions: decorate(cacheRows), source: 'controller' };
|
|
86
|
+
}
|
|
61
87
|
const [local, remote] = await Promise.allSettled([
|
|
62
88
|
loadLocal({ root, signal, provider, includeArchived, readLinks }),
|
|
63
89
|
localOnly ? [] : loadRemote({ root, signal, providers, includeArchived }),
|
|
@@ -83,8 +109,14 @@ export async function readSessionList({ root, signal, provider = 'all', localOnl
|
|
|
83
109
|
workState: { label: terminalText(row.workState?.label || '状態未確認') },
|
|
84
110
|
})));
|
|
85
111
|
}
|
|
112
|
+
// Keep offline history from the same authorized catalog, explicitly stale.
|
|
113
|
+
const identities = new Set(sessions.map(row => JSON.stringify([row.hostId || 'local', row.provider, row.session])));
|
|
114
|
+
for (const row of cacheRows) {
|
|
115
|
+
const key = JSON.stringify([row.hostId || 'local', row.provider, row.session]);
|
|
116
|
+
if (!identities.has(key)) sessions.push({ ...row, isRunning: false, workState: { state: 'unknown', label: '接続先を確認できません', stale: true, checkedAt: cache.observedAt }, statistics: row.statistics ? { ...row.statistics, stale: true, cached: true } : undefined });
|
|
117
|
+
}
|
|
86
118
|
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()] };
|
|
119
|
+
sessions: decorate([...new Map(sessions.map(row => [`${row.hostId}:${row.provider}:${row.session}`, row])).values()]) };
|
|
88
120
|
}
|
|
89
121
|
|
|
90
122
|
export function formatSessionList(catalog, query = '') {
|
|
@@ -101,6 +133,10 @@ export function formatSessionList(catalog, query = '') {
|
|
|
101
133
|
|
|
102
134
|
export async function runSessionListCommand(argv, { output = process.stdout, errors = process.stderr, input = process.stdin,
|
|
103
135
|
load = readSessionList, renderInteractive } = {}) {
|
|
136
|
+
if (['archive', 'restore', 'provider-status', 'provider-setup'].includes(argv[0])) {
|
|
137
|
+
const { runSessionArchiveCommand } = await import('./session-archive-command.js');
|
|
138
|
+
return runSessionArchiveCommand(argv, { output, errors });
|
|
139
|
+
}
|
|
104
140
|
let query = '', json = false, interactive = false, includeArchived = false, localOnly = false, provider = 'all';
|
|
105
141
|
try {
|
|
106
142
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -1,14 +1,57 @@
|
|
|
1
|
+
const count = value => Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
2
|
+
const belongsToConversation = (candidate, check) => check.conversationProvider === candidate.provider && check.providerSession === candidate.session;
|
|
3
|
+
const newestFirst = (a, b) => (b.finishedAt || b.startedAt || '').localeCompare(a.finishedAt || a.startedAt || '');
|
|
4
|
+
const checkScope = check => check?.phase === 'min' ? 'Quick review of the changed work'
|
|
5
|
+
: check?.phase === 'full' ? 'Review of the changed work and its checks' : null;
|
|
6
|
+
const newestAttemptFirst = (a, b) => (b.startedAt || b.finishedAt || '').localeCompare(a.startedAt || a.finishedAt || '') || newestFirst(a, b);
|
|
7
|
+
|
|
8
|
+
// A description is not an executed check. Only recorded inspection coverage
|
|
9
|
+
// supplies a count; historical rows without it remain unconfirmed.
|
|
10
|
+
export function sqmResultPresentation(check) {
|
|
11
|
+
const checkCount = count(check?.checkCount);
|
|
12
|
+
const findingsCount = count(check?.findings);
|
|
13
|
+
const passedCheckCount = count(check?.passedCheckCount);
|
|
14
|
+
let state;
|
|
15
|
+
if (!check) state = 'not_run';
|
|
16
|
+
else if (check.state === 'running') state = 'running';
|
|
17
|
+
else if (['unavailable', 'interrupted'].includes(check.state) ||
|
|
18
|
+
check.state === 'failed' && (!['failed', 'preliminary_failed'].includes(check.outcome) ||
|
|
19
|
+
check.errorStage && check.errorStage !== 'evidence_upload')) state = 'unavailable';
|
|
20
|
+
else if (check.outcome === 'failed' || check.outcome === 'preliminary_failed' || findingsCount > 0) state = 'findings';
|
|
21
|
+
else if (check.state === 'verification_pending') state = 'needs_check';
|
|
22
|
+
else if (check.noApplicableChecks === true || checkCount === 0) state = 'no_applicable';
|
|
23
|
+
else if (check.state === 'completed' && check.outcome === 'passed' && check.phase === 'full' && checkCount > 0 &&
|
|
24
|
+
check.inspectionStatus === 'verified' && passedCheckCount === checkCount) state = 'passed';
|
|
25
|
+
else state = 'unconfirmed';
|
|
26
|
+
return { state, checkCount, passedCheckCount, inspectionStatus: check?.inspectionStatus || null, findingsCount, icon: state === 'passed' ? 'check' : state === 'findings' || state === 'needs_check' ? 'attention' : 'neutral',
|
|
27
|
+
checkedAt: check?.finishedAt || check?.startedAt || null, evidenceKind: checkCount === null ? null : 'static_check',
|
|
28
|
+
canRetry: state === 'unavailable' };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The latest result lane includes unavailable/interrupted attempts. Keep the
|
|
32
|
+
// historical recorded-results list below backwards compatible; hiding a failed
|
|
33
|
+
// attempt there must not make the current lane display an older success.
|
|
34
|
+
export function conversationSqmResult(candidate, checks) {
|
|
35
|
+
const latest = checks.filter(check => belongsToConversation(candidate, check)).sort(newestAttemptFirst)[0];
|
|
36
|
+
return { ...sqmResultPresentation(latest), id: latest?.key || null,
|
|
37
|
+
// Unavailable attempts are absent from the legacy history list. Retain
|
|
38
|
+
// their observed detail metadata without deriving counts from description.
|
|
39
|
+
checked: Array.isArray(latest?.checked) ? [...latest.checked] : null,
|
|
40
|
+
scope: checkScope(latest) };
|
|
41
|
+
}
|
|
42
|
+
|
|
1
43
|
// Never associate by repository or the AI that executed an analysis. A result
|
|
2
44
|
// belongs under a conversation only when the original provider/session match.
|
|
3
45
|
export function conversationSqmChecks(candidate, checks) {
|
|
4
|
-
return checks.filter(check =>
|
|
5
|
-
!['unavailable', 'interrupted'].includes(check.state) && (check.state !== 'failed' || ['failed', 'preliminary_failed'].includes(check.outcome) && (!check.errorStage || check.errorStage === 'evidence_upload'))).sort(
|
|
46
|
+
return checks.filter(check => belongsToConversation(candidate, check) &&
|
|
47
|
+
!['unavailable', 'interrupted'].includes(check.state) && (check.state !== 'failed' || ['failed', 'preliminary_failed'].includes(check.outcome) && (!check.errorStage || check.errorStage === 'evidence_upload'))).sort(newestFirst).slice(0, 5).map(check => ({
|
|
6
48
|
id: check.key, state: check.state, checkedAt: check.finishedAt || check.startedAt,
|
|
7
|
-
scope: check
|
|
49
|
+
scope: checkScope(check),
|
|
8
50
|
result: check.state === 'running' ? 'Checking this work now.' : check.state === 'verification_pending'
|
|
9
51
|
? 'Some checks still need a person to confirm the result.' : check.outcome === 'preliminary_failed'
|
|
10
52
|
? 'The quick review found issues that need attention.' : check.outcome === 'failed' ? 'The review found issues that need attention.' : check.outcome === 'passed'
|
|
11
53
|
? check.noApplicableChecks ? 'No applicable checks were recorded for this work.' : check.phase === 'min' ? 'The quick review found no blocking issues.' : 'The recorded checks passed.' : 'The review finished. Open the recorded result for its coverage.',
|
|
12
54
|
checked: check.checked || [], findings: check.findings ?? null,
|
|
55
|
+
presentation: sqmResultPresentation(check),
|
|
13
56
|
}));
|
|
14
57
|
}
|
|
@@ -1,29 +1,55 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import {
|
|
2
|
+
import { repairNotificationAuthority } from './notification-authority.js';
|
|
3
|
+
import { deliveryStop, pendingAcceptance } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
3
4
|
|
|
4
|
-
const
|
|
5
|
-
item
|
|
5
|
+
const knownAcceptance = run => Array.isArray(run?.managed?.acceptanceCriteria) && run.managed.acceptanceCriteria.length > 0 &&
|
|
6
|
+
run.managed.acceptanceCriteria.every(item => typeof item?.id === 'string' && item.id && ['manual', 'semantic', 'file_exists', 'file_contains', 'file_equals'].includes(item.check?.type)) &&
|
|
7
|
+
new Set(run.managed.acceptanceCriteria.map(item => item.id)).size === run.managed.acceptanceCriteria.length &&
|
|
8
|
+
run.managed.verified && typeof run.managed.verified === 'object' && !Array.isArray(run.managed.verified);
|
|
9
|
+
const completionEvidence = run => run.managed.acceptanceCriteria.every(item => {
|
|
10
|
+
const proof = run.managed.verified[item.id];
|
|
11
|
+
return proof && ['user', 'user_attestation', 'file_exists', 'file_contains', 'file_equals', 'independent_codex_session', 'independent_claude_review'].includes(proof.source) &&
|
|
12
|
+
Number.isFinite(proof.checkedAt) && proof.checkedAt > 0 && (!Object.hasOwn(proof, 'passed') || proof.passed === true) &&
|
|
13
|
+
!['failed', 'error', 'unverified', 'not_run'].includes(proof.status);
|
|
14
|
+
});
|
|
15
|
+
const pendingManualChecks = run => knownAcceptance(run) ? pendingAcceptance(run).filter(item => item.check === 'manual' && item.id !== 'enrollment') : [];
|
|
16
|
+
const identity = value => typeof value === 'string' && value.length > 0 && value.length <= 4000 && !/[\u0000-\u001f\u007f]/u.test(value);
|
|
6
17
|
|
|
7
18
|
// Auto-continue works while nobody is watching, so a person must learn when it
|
|
8
19
|
// stops. Map a stopped run to a notification kind; the native window owns copy.
|
|
9
20
|
export function stopNotificationKind(run, now = Date.now()) {
|
|
10
|
-
if (!run?.managed?.autoContinueEpoch && !run
|
|
21
|
+
if (!run?.managed?.autoContinueEpoch && !repairNotificationAuthority(run)) return null;
|
|
11
22
|
if (run.stopReason?.actor === 'user') return null;
|
|
12
23
|
// The clock keeps aging while paused; the persisted human decision takes
|
|
13
24
|
// precedence over a threshold that was reached only after that decision.
|
|
14
25
|
if (run.state === 'paused') return /^(Paused by user\.|Automatic continuation paused by user\.|User interrupted the provider\.|Stopped by user\.)$/.test(run.reason || '') ? null : deliveryStop(run, now) ? 'paused' : null;
|
|
15
26
|
if (run.state === 'auto_resuming' && !run.managed.resumeLease?.owner) return 'needs_decision';
|
|
27
|
+
if (run.state === 'blocked' && run.managed.controllerDecisionWait && !run.providerRequest) return null;
|
|
16
28
|
if (run.state === 'blocked') return run.blocker === 'external_dependency' || run.managed.dependencyWait ? null : 'needs_decision';
|
|
17
29
|
if (run.state === 'verification_pending') return pendingManualChecks(run).length ? 'needs_decision' : null;
|
|
18
30
|
if (run.state === 'recovery_required' && run.managed.autoRecovery !== true) return 'needs_decision';
|
|
19
31
|
if (run.state === 'failed' || run.state === 'budget_exhausted') return 'failed';
|
|
20
|
-
if (run.state === 'completed') return 'finished';
|
|
32
|
+
if (run.state === 'completed') return knownAcceptance(run) && pendingAcceptance(run).length === 0 && completionEvidence(run) ? 'finished' : null;
|
|
21
33
|
return null;
|
|
22
34
|
}
|
|
23
35
|
|
|
36
|
+
// Shared by source events and read-only desktop projection. This opaque value
|
|
37
|
+
// binds a stopped continuation's epoch without exposing its private lease data.
|
|
38
|
+
export function stopNotificationId(record, kind) {
|
|
39
|
+
const run = record?.run;
|
|
40
|
+
if (!['needs_decision', 'failed', 'paused', 'finished'].includes(kind) ||
|
|
41
|
+
!['codex', 'claude'].includes(record?.provider) || !identity(record?.session) ||
|
|
42
|
+
!identity(run?.id) || !identity(run?.managed?.taskId)) return null;
|
|
43
|
+
const request = run.managed.pendingApproval || run.managed.understandingQuestion;
|
|
44
|
+
const criterionIds = run.state === 'verification_pending' ? pendingManualChecks(run).map(item => item.id).sort() : [];
|
|
45
|
+
return createHash('sha256').update(JSON.stringify([record.provider, record.session, run.managed.taskId,
|
|
46
|
+
run.id, run.managed.autoContinueEpoch, kind, run.blocker || null, request?.id || null, run.providerRequest?.id || null,
|
|
47
|
+
run.managed.userQuestion || null, criterionIds, repairNotificationAuthority(run)?.id || null])).digest('hex').slice(0, 32);
|
|
48
|
+
}
|
|
49
|
+
|
|
24
50
|
// The first observation is a baseline: restarting SIMY must not replay alerts
|
|
25
51
|
// for runs that stopped earlier. Each distinct stop is announced once.
|
|
26
|
-
export function createStopNotifier({ emit, now = Date.now } = {}) {
|
|
52
|
+
export function createStopNotifier({ hostId, emit, now = Date.now } = {}) {
|
|
27
53
|
// Retain one stop fingerprint per task for this process. A temporarily empty
|
|
28
54
|
// discovery result must not turn the same historical stop into a new alert.
|
|
29
55
|
const seen = new Map();
|
|
@@ -34,7 +60,11 @@ export function createStopNotifier({ emit, now = Date.now } = {}) {
|
|
|
34
60
|
if (record.providerArchived || record.localHidden) continue;
|
|
35
61
|
const run = record?.run;
|
|
36
62
|
const kind = stopNotificationKind(run, now());
|
|
37
|
-
|
|
63
|
+
// hostId comes from the record-reader's verified connection, never a
|
|
64
|
+
// guessed alias or a provider frame. Unknown legacy identities stay quiet.
|
|
65
|
+
if (!identity(hostId) || !['codex', 'claude'].includes(record.provider) || !identity(record.session) ||
|
|
66
|
+
!identity(run?.id) || !identity(run?.managed?.taskId)) continue;
|
|
67
|
+
const key = JSON.stringify([hostId, record.provider, record.session, run.managed.taskId]);
|
|
38
68
|
if (!kind) {
|
|
39
69
|
if (run?.state === 'active' || run?.state === 'auto_resuming') seen.delete(key);
|
|
40
70
|
continue;
|
|
@@ -42,14 +72,15 @@ export function createStopNotifier({ emit, now = Date.now } = {}) {
|
|
|
42
72
|
// Revisions and generated summaries change during maintenance without
|
|
43
73
|
// creating a new human task. Only a different stop/explicit request does.
|
|
44
74
|
const request = run.managed.pendingApproval || run.managed.understandingQuestion;
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
run.managed.userQuestion || null])).digest('hex').slice(0, 32);
|
|
75
|
+
const criterionIds = run.state === 'verification_pending' ? pendingManualChecks(run).map(item => item.id).sort() : [];
|
|
76
|
+
const id = stopNotificationId(record, kind);
|
|
48
77
|
if (seen.get(key) === id) continue;
|
|
49
78
|
seen.set(key, id);
|
|
50
79
|
const detail = request?.text || request?.summary || run.managed.userQuestion ||
|
|
51
80
|
(run.state === 'verification_pending' ? pendingManualChecks(run).map(item => item.description).filter(Boolean).join('; ') : null);
|
|
52
|
-
if (!baseline) emit({ type: 'notify', id, kind, provider: record.provider, session: record.session,
|
|
81
|
+
if (!baseline) emit({ type: 'notify', id, kind, hostId, provider: record.provider, session: record.session,
|
|
82
|
+
runId: run.id, taskId: run.managed.taskId,
|
|
83
|
+
...(repairNotificationAuthority(run) ? { repairRequestId: repairNotificationAuthority(run).id } : {}), ...(criterionIds.length ? { criterionIds } : {}),
|
|
53
84
|
objective: String(run.objective || '').slice(0, 200),
|
|
54
85
|
...(detail ? { detail: String(detail).slice(0, 500) } : {}) });
|
|
55
86
|
}
|
|
@@ -1,13 +1,71 @@
|
|
|
1
1
|
// Display-only state. Tracking/auto-continue consent is never evidence of execution.
|
|
2
2
|
export const WORK_STATE_TTL_MS = 45_000;
|
|
3
3
|
const freshObservation = (checkedAt, now) => Number.isFinite(checkedAt) && checkedAt <= now && now - checkedAt <= WORK_STATE_TTL_MS;
|
|
4
|
+
// Ownership is a presentation axis, independent of the precise work state.
|
|
5
|
+
// A saved preference, resumable task or next-step string cannot prove that a
|
|
6
|
+
// controller can execute it (notably an ended Claude Stop-hook conversation).
|
|
7
|
+
const OWNERSHIP_ORDER = { needs_you: 0, ai_working: 1, simy_continues: 2, waiting: 3, done: 4, archived: 5 };
|
|
8
|
+
function scheduledContinuation(candidate, run) {
|
|
9
|
+
if (candidate.dispatchReadiness?.state === 'blocked') return false;
|
|
10
|
+
const managed = run?.managed && typeof run.managed === 'object' ? run.managed : {};
|
|
11
|
+
if (run?.pendingApproval || managed.pendingApproval || run?.understandingQuestion || managed.understandingQuestion ||
|
|
12
|
+
managed.userQuestion || run?.dependencyWait || managed.dependencyWait || run?.executionUncertain || managed.executionUncertain ||
|
|
13
|
+
managed.nativeWait || managed.purpose?.nativeBoundary || run?.providerWarning || managed.providerWarning ||
|
|
14
|
+
candidate.decisionRequest?.kind === 'approval' || candidate.decisionRequest?.kind === 'question') return false;
|
|
15
|
+
// Acceptance-pending is a stopped execution lane. A controller capability or
|
|
16
|
+
// a resumable next-step string does not schedule another provider turn.
|
|
17
|
+
if (run?.state === 'verification_pending') return false;
|
|
18
|
+
if (['starting', 'recovering'].includes(candidate.autoContinueStatus) && candidate.autoContinue === true) return true;
|
|
19
|
+
if (['queued', 'analyzing'].includes(candidate.analysis?.state)) return true;
|
|
20
|
+
const autoRecovery = run?.autoRecovery === true || managed.autoRecovery === true;
|
|
21
|
+
if (autoRecovery && ['recovery_required', 'auto_resuming'].includes(run?.state)) return true;
|
|
22
|
+
// This channel has an actual host controller, unlike a passive Stop hook.
|
|
23
|
+
const controlled = candidate.provider === 'codex' &&
|
|
24
|
+
(run?.capability === 'codex_app_server' || managed.resumeChannel === 'codex_app_server');
|
|
25
|
+
return controlled && candidate.autoContinue === true && candidate.dispatchReadiness?.state === 'ready' &&
|
|
26
|
+
candidate.autoContinueStatus === 'supervising' && run?.state === 'active';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function onlyManualAcceptancePending(run) {
|
|
30
|
+
if (run?.state !== 'verification_pending') return false;
|
|
31
|
+
if (Array.isArray(run.pendingAcceptance)) return run.pendingAcceptance.length > 0 && run.pendingAcceptance.every(item => item.check === 'manual');
|
|
32
|
+
const managed = run.managed;
|
|
33
|
+
if (!managed || typeof managed !== 'object' || !Array.isArray(managed.acceptanceCriteria) || managed.independentReviewRequired) return false;
|
|
34
|
+
const pending = managed.acceptanceCriteria.filter(item => !Object.hasOwn(managed.verified || {}, item.id));
|
|
35
|
+
return pending.length > 0 && pending.every(item => item.check?.type === 'manual');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function sessionWorkOwnership(work, candidate = {}, run) {
|
|
39
|
+
let ownership;
|
|
40
|
+
switch (work?.state) {
|
|
41
|
+
case 'running': ownership = 'ai_working'; break;
|
|
42
|
+
case 'approval': case 'question': case 'blocked': case 'error': case 'stopped': ownership = 'needs_you'; break;
|
|
43
|
+
case 'starting': case 'recovering':
|
|
44
|
+
ownership = scheduledContinuation(candidate, run) ? 'simy_continues' : 'waiting'; break;
|
|
45
|
+
case 'checking':
|
|
46
|
+
ownership = onlyManualAcceptancePending(run) ? 'needs_you' : scheduledContinuation(candidate, run) ? 'simy_continues' : 'waiting'; break;
|
|
47
|
+
case 'unfinished':
|
|
48
|
+
ownership = scheduledContinuation(candidate, run) ? 'simy_continues' : 'needs_you'; break;
|
|
49
|
+
case 'completed': case 'idle': ownership = 'done'; break;
|
|
50
|
+
case 'archived': ownership = 'archived'; break;
|
|
51
|
+
case 'waiting': case 'review': case 'changes_requested': case 'pr_unknown': case 'unknown': case 'family_unfinished':
|
|
52
|
+
ownership = 'waiting'; break;
|
|
53
|
+
// Future and incomplete states never manufacture a decision or execution.
|
|
54
|
+
default: ownership = 'waiting';
|
|
55
|
+
}
|
|
56
|
+
return { ownership, ownershipReason: work?.reason || '' };
|
|
57
|
+
}
|
|
58
|
+
|
|
4
59
|
export function sessionWorkState(candidate, run, { now = Date.now(), provider } = {}) {
|
|
5
60
|
const checkedAt = candidate.observation?.observedAt ?? null;
|
|
6
61
|
const stale = !freshObservation(checkedAt, now);
|
|
7
62
|
const fetchFailed = provider?.discovery === 'unavailable';
|
|
8
63
|
const freshness = { checkedAt, stale, fetchFailed };
|
|
9
|
-
const result = (state, label, reason, nextAction = null, needsAttention = false) =>
|
|
10
|
-
|
|
64
|
+
const result = (state, label, reason, nextAction = null, needsAttention = false) => {
|
|
65
|
+
const work = { state, label, reason, nextAction, needsAttention, ...freshness };
|
|
66
|
+
const ownership = sessionWorkOwnership(work, candidate, run);
|
|
67
|
+
return { ...work, ...ownership, needsAttention: ownership.ownership === 'simy_continues' ? false : needsAttention || onlyManualAcceptancePending(run) };
|
|
68
|
+
};
|
|
11
69
|
if (candidate.archived || candidate.localHidden) return result('archived', 'アーカイブ済み', candidate.localHidden ? 'この一覧だけで非表示にしています。' : '元の会話がアーカイブされています。', 'restore');
|
|
12
70
|
let nativeRuntimeUnknown = false;
|
|
13
71
|
if (Object.hasOwn(candidate.observation || {}, 'activeFlags')) {
|
|
@@ -18,6 +76,10 @@ export function sessionWorkState(candidate, run, { now = Date.now(), provider }
|
|
|
18
76
|
}
|
|
19
77
|
// Never display a cached active flag as a live observation after a sleep/outage.
|
|
20
78
|
if (!nativeRuntimeUnknown && !stale && !fetchFailed && candidate.isRunning === true) return result('running', 'AIが対応中', '元のAIが実行中です。');
|
|
79
|
+
if (run?.state === 'blocked' && run.providerRequest?.source === 'provider_hook') {
|
|
80
|
+
const question = run.providerRequest.kind === 'question';
|
|
81
|
+
return result(question ? 'question' : 'approval', question ? '回答待ち' : '承認待ち', '元のAIが現在の権限要求・質問への対応を待っています。', 'open_original', true);
|
|
82
|
+
}
|
|
21
83
|
const approval = run?.pendingApproval || run?.managed?.pendingApproval;
|
|
22
84
|
const question = run?.understandingQuestion || run?.managed?.understandingQuestion || run?.managed?.userQuestion;
|
|
23
85
|
const phase = run?.phase || run?.managed?.phase;
|
|
@@ -28,6 +90,14 @@ export function sessionWorkState(candidate, run, { now = Date.now(), provider }
|
|
|
28
90
|
return result('approval', '承認待ち', candidate.decisionRequest?.context || '実行する操作の確認が必要です。', 'review_approval', true);
|
|
29
91
|
if (question || phase === 'needs_clarification' || candidate.analysis?.state === 'needs_user' || candidate.decisionRequest?.kind === 'question')
|
|
30
92
|
return result('question', '回答待ち', candidate.decisionRequest?.question || '続けるために回答が必要です。', 'answer', true);
|
|
93
|
+
// Saved consent is not execution: name the real dispatch blocker rather than
|
|
94
|
+
// presenting a fenced conversation as waiting for its AI.
|
|
95
|
+
if (candidate.autoContinue === true && candidate.dispatchReadiness?.state === 'blocked' && !['completed', 'stopped'].includes(run?.state))
|
|
96
|
+
return result('blocked', '自動継続を送信できません', candidate.autoContinueReason || '自動継続はオンですが、作業を送信できません。',
|
|
97
|
+
candidate.dispatchReadiness.code === 'update_recovery_required' ? 'update_reconnect' : candidate.dispatchReadiness.code === 'writer_reconnect_required' ? 'reconnect_original' : 'open_original', true);
|
|
98
|
+
if (run?.state === 'verification_pending' &&
|
|
99
|
+
(onlyManualAcceptancePending(run) || candidate.autoContinueStatus === 'needs_user'))
|
|
100
|
+
return result('unfinished', '残りの作業あり', '実際の結果の確認が残っています。', 'open_original', true);
|
|
31
101
|
if (candidate.autoContinue === true && ['starting', 'recovering'].includes(candidate.autoContinueStatus))
|
|
32
102
|
return result(candidate.autoContinueStatus, candidate.autoContinueStatus === 'starting' ? '自動継続を準備中' : '自動復旧中',
|
|
33
103
|
candidate.autoContinueReason || '自動継続はオンで保存されています。');
|
|
@@ -37,7 +107,10 @@ export function sessionWorkState(candidate, run, { now = Date.now(), provider }
|
|
|
37
107
|
if (['failed', 'recovery_required', 'budget_exhausted'].includes(run?.state) || candidate.analysis?.state === 'failed' || candidate.autoContinueStatus === 'error')
|
|
38
108
|
return result('error', '対応が必要', run?.reason || '作業が中断されています。', run?.canResume || run?.canReplan ? 'resume' : 'open_original', true);
|
|
39
109
|
if (run?.dependencyWait || run?.managed?.dependencyWait) return result('waiting', '依存条件待ち', '条件が変わるまで待機します。');
|
|
40
|
-
if (['
|
|
110
|
+
if (['verification_pending', 'reported_done'].includes(run?.state))
|
|
111
|
+
return result('checking', '完了状態を確認中', '実際の結果の確認が残っています。');
|
|
112
|
+
if (run?.state === 'blocked' && candidate.autoContinueStatus === 'watching') return result('checking', 'AIが状況確認中', candidate.autoContinueReason || '既存の許可と実行条件を確認しています。');
|
|
113
|
+
if (['blocked', 'paused'].includes(run?.state) || candidate.analysis?.state === 'continue' && candidate.autoContinue !== true || candidate.autoContinueStatus === 'needs_user')
|
|
41
114
|
return result('unfinished', '残りの作業あり', run?.reason || candidate.analysis?.reason || '未完了の作業を再開できます。', 'resume', true);
|
|
42
115
|
if (nativeRuntimeUnknown) return result('unknown', '実行状態を確認できません', '元のAIの最新の実行状態を確認できません。', 'refresh');
|
|
43
116
|
const prs = candidate.pullRequests || [];
|
|
@@ -55,13 +128,22 @@ export function sessionWorkState(candidate, run, { now = Date.now(), provider }
|
|
|
55
128
|
|
|
56
129
|
export function compareSessionWork(a, b) {
|
|
57
130
|
const priority = item => item.workState?.state === 'running' ? 0 : item.workState?.needsAttention ? 1 : 2;
|
|
58
|
-
|
|
131
|
+
const group = item => item.workState?.ownership || sessionWorkOwnership(item.workState, item).ownership;
|
|
132
|
+
const groupPriority = item => OWNERSHIP_ORDER[group(item)] ?? OWNERSHIP_ORDER.waiting;
|
|
133
|
+
const paused = item => group(item) === 'needs_you' && item.workState?.state === 'stopped' ? 1 : 0;
|
|
134
|
+
return groupPriority(a) - groupPriority(b) || paused(a) - paused(b) || priority(a) - priority(b) ||
|
|
135
|
+
(b.lastActivityAt || 0) - (a.lastActivityAt || 0) || `${a.provider}:${a.session}`.localeCompare(`${b.provider}:${b.session}`);
|
|
59
136
|
}
|
|
60
137
|
|
|
61
138
|
// Completion belongs to the whole known family. Idle history alone does not
|
|
62
139
|
// create a human task; positively unfinished descendants do prevent completion.
|
|
63
140
|
export function reconcileFamilyCompletion(sessions) {
|
|
64
141
|
const byKey = new Map(sessions.map(item => [`${item.provider}:${item.session}`, item]));
|
|
142
|
+
// Reconciliation may run over a cached frame after children have expired.
|
|
143
|
+
// Derive the family owner again rather than retaining yesterday's activity.
|
|
144
|
+
for (const item of sessions) if (item.workState?.state === 'family_unfinished') {
|
|
145
|
+
item.workState = { ...item.workState, ownership: 'waiting', ownershipReason: item.workState.reason, ownershipObservedAt: null };
|
|
146
|
+
}
|
|
65
147
|
for (const child of sessions) {
|
|
66
148
|
if (['idle', 'completed', 'archived'].includes(child.workState?.state)) continue;
|
|
67
149
|
const seen = new Set(); let parent = child.parentSession;
|
|
@@ -71,7 +153,19 @@ export function reconcileFamilyCompletion(sessions) {
|
|
|
71
153
|
seen.add(key); const ancestor = byKey.get(key);
|
|
72
154
|
if (!ancestor) break;
|
|
73
155
|
if (ancestor.workState?.state === 'completed') ancestor.workState = { ...ancestor.workState,
|
|
74
|
-
state: 'family_unfinished', label: '子の作業が未完了', reason: '関連する子の作業が残っています。', nextAction: 'review_children', needsAttention: false
|
|
156
|
+
state: 'family_unfinished', label: '子の作業が未完了', reason: '関連する子の作業が残っています。', nextAction: 'review_children', needsAttention: false,
|
|
157
|
+
ownership: 'waiting', ownershipReason: '関連する子の作業が残っています。' };
|
|
158
|
+
if (ancestor.workState?.state === 'family_unfinished') {
|
|
159
|
+
const childOwner = child.workState?.ownership || sessionWorkOwnership(child.workState, child).ownership;
|
|
160
|
+
const currentOwner = ancestor.workState.ownership;
|
|
161
|
+
const childObservedAt = child.workState.ownershipObservedAt ?? child.workState.checkedAt;
|
|
162
|
+
if ((OWNERSHIP_ORDER[childOwner] ?? OWNERSHIP_ORDER.waiting) < (OWNERSHIP_ORDER[currentOwner] ?? OWNERSHIP_ORDER.waiting) ||
|
|
163
|
+
childOwner === 'ai_working' && currentOwner === childOwner && Number.isFinite(childObservedAt) &&
|
|
164
|
+
childObservedAt > (ancestor.workState.ownershipObservedAt ?? -Infinity)) {
|
|
165
|
+
ancestor.workState = { ...ancestor.workState, ownership: childOwner,
|
|
166
|
+
ownershipObservedAt: childOwner === 'ai_working' ? childObservedAt : null };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
75
169
|
parent = ancestor.parentSession;
|
|
76
170
|
}
|
|
77
171
|
}
|
|
@@ -82,8 +176,14 @@ export function reconcileFamilyCompletion(sessions) {
|
|
|
82
176
|
// reader is blocked. Expire live labels there too, without losing pending work.
|
|
83
177
|
export function expireSessionWorkState(candidate, now = Date.now()) {
|
|
84
178
|
const previous = candidate.workState;
|
|
179
|
+
// Family activity inherits the child's observation clock, not the parent's.
|
|
180
|
+
if (previous?.state === 'family_unfinished' && previous.ownership === 'ai_working' &&
|
|
181
|
+
!freshObservation(previous.ownershipObservedAt ?? previous.checkedAt, now)) {
|
|
182
|
+
return { ...candidate, workState: { ...previous, ownership: 'waiting', ownershipReason: previous.reason, stale: true } };
|
|
183
|
+
}
|
|
85
184
|
if (!previous || freshObservation(previous.checkedAt, now)) return candidate;
|
|
86
185
|
if (previous.state !== 'running') return previous.stale ? candidate : { ...candidate, workState: { ...previous, stale: true } };
|
|
87
186
|
return { ...candidate, workState: { ...previous, state: 'unknown', label: '実行状態を確認できません',
|
|
88
|
-
reason: '最終確認後の実行状態は不明です。', nextAction: 'refresh', needsAttention: false, stale: true
|
|
187
|
+
reason: '最終確認後の実行状態は不明です。', nextAction: 'refresh', needsAttention: false, stale: true,
|
|
188
|
+
ownership: 'waiting', ownershipReason: '最終確認後の実行状態は不明です。' } };
|
|
89
189
|
}
|
package/src/sqm/agent-skill.js
CHANGED
|
@@ -8,7 +8,7 @@ import { hookRuntimeCommand } from "../hook-runtime.js";
|
|
|
8
8
|
import { inspectClaudePlugin, installClaudePlugin } from "./claude-plugin.js";
|
|
9
9
|
|
|
10
10
|
export const SQM_AGENT_SKILL_ID = "simy-sqm-pr-check";
|
|
11
|
-
export const SQM_AGENT_SKILL_VERSION = "3.
|
|
11
|
+
export const SQM_AGENT_SKILL_VERSION = "3.4.0";
|
|
12
12
|
export const SIMY_CODEX_PLUGIN_ID = "simy";
|
|
13
13
|
export const SIMY_CODEX_PLUGIN_VERSION = "0.1.0";
|
|
14
14
|
|
|
@@ -22,12 +22,12 @@ const LEGACY_SKILL_DIGEST = "c9ab46e855280196dbc93fd36ba849418e0c4e500dd6e4b7b03
|
|
|
22
22
|
|
|
23
23
|
const SKILL_CONTENT = `---
|
|
24
24
|
name: ${SQM_AGENT_SKILL_ID}
|
|
25
|
-
description: Run SIMY Software Quality Monitor checks for PR work when the user
|
|
25
|
+
description: Run SIMY Software Quality Monitor checks for PR work when the user asks @simy for a quality check, invokes this SQM skill, or asks to use SQM. Conversation continuation uses the separate continue entry. Ordinary PR creation or git push alone does not activate this skill.
|
|
26
26
|
---
|
|
27
27
|
|
|
28
28
|
# SIMY SQM PR check
|
|
29
29
|
|
|
30
|
-
Apply this workflow only when the user has explicitly
|
|
30
|
+
Apply this workflow only when the user has explicitly requested quality checks with \`@simy\`, invoked this SQM skill, or requested SQM for the current task. A request to start or continue the original task uses SIMY's \`continue\` entry; selecting that entry does not request SQM. A quoted mention, repository organization, installed plugin, or ordinary request to create a PR is not opt-in. Honor a later request to stop using SQM.
|
|
31
31
|
|
|
32
32
|
Without opt-in, continue the requested work without running SQM or making it a prerequisite. You may briefly offer SQM if useful; do not delay the work waiting for an optional answer. Follow any independent repository checks required by the project.
|
|
33
33
|
|