@awak-app/simy-cli 0.7.28 → 0.7.31
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 +4 -4
- package/src/agent.js +1 -0
- 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 +570 -65
- package/src/codex-plugins/simy/hooks/purpose-evidence.mjs +6 -3
- package/src/codex-plugins/simy/hooks/purpose-search.mjs +23 -4
- package/src/codex-plugins/simy/hooks/scene-reasoning.mjs +2 -0
- 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 +12 -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-executor.js +6 -0
- 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/budget.js +24 -0
- package/src/orchestrator/charter-repair.js +1 -1
- package/src/orchestrator/completion-contract.js +8 -2
- package/src/orchestrator/contract.js +3 -1
- package/src/orchestrator/independent-audit.js +13 -6
- package/src/orchestrator/instruction.js +1 -1
- package/src/orchestrator/loop.js +34 -4
- package/src/provider-stream.js +9 -2
- package/src/runner.js +44 -21
- 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-outcome-review.js +132 -0
- package/src/session-supervision/claude-owner-renewal.js +197 -0
- package/src/session-supervision/claude-review-definition.js +5 -0
- package/src/session-supervision/claude-sessions.js +210 -40
- package/src/session-supervision/codex-app-server.js +189 -36
- 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 +38 -9
- 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/purpose-verification.js +103 -9
- 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 +7 -2
- package/src/sqm/history-store.js +6 -0
- package/src/team-threads.js +1 -1
- 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
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const conversationKey = ({ hostId = 'local', provider, session }) => JSON.stringify([hostId, provider, session]);
|
|
6
|
+
export const controllerScope = status => status?.auth?.userId && status?.access?.organizationId
|
|
7
|
+
? JSON.stringify([status.auth.userId, status.access.organizationId]) : null;
|
|
8
|
+
const digest = value => createHash('sha256').update(value).digest('hex');
|
|
9
|
+
|
|
10
|
+
// User preferences belong to the controlling device, never to an SSH target.
|
|
11
|
+
// Each account/organization has a separate file. All mutations serialize and
|
|
12
|
+
// publish only after an atomic durable write; failed writes preserve memory.
|
|
13
|
+
export function createControllerStore(root) {
|
|
14
|
+
let queue = Promise.resolve();
|
|
15
|
+
const states = new Map();
|
|
16
|
+
const file = scope => join(root, 'controller', `${digest(scope)}.json`);
|
|
17
|
+
async function load(scope) {
|
|
18
|
+
if (!scope) throw new Error('Controller identity unavailable.');
|
|
19
|
+
if (states.has(scope)) return states.get(scope);
|
|
20
|
+
let state;
|
|
21
|
+
try { state = JSON.parse(await readFile(file(scope), 'utf8')); }
|
|
22
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; state = { version: 1, conversations: {}, events: {} }; }
|
|
23
|
+
if (state.version !== 1 || !state.conversations || !state.events) throw new Error('Invalid controller settings.');
|
|
24
|
+
states.set(scope, state); return state;
|
|
25
|
+
}
|
|
26
|
+
function change(scope, mutate) {
|
|
27
|
+
const work = queue.catch(() => {}).then(async () => {
|
|
28
|
+
const next = structuredClone(await load(scope));
|
|
29
|
+
const result = await mutate(next);
|
|
30
|
+
await mkdir(join(root, 'controller'), { recursive: true, mode: 0o700 });
|
|
31
|
+
const path = file(scope), temp = `${path}.${randomUUID()}.tmp`;
|
|
32
|
+
try { await writeFile(temp, JSON.stringify(next) + '\n', { mode: 0o600 }); await rename(temp, path); }
|
|
33
|
+
finally { await rm(temp, { force: true }); }
|
|
34
|
+
states.set(scope, next); return result;
|
|
35
|
+
});
|
|
36
|
+
queue = work; return work;
|
|
37
|
+
}
|
|
38
|
+
return { load, change, peek: scope => states.get(scope),
|
|
39
|
+
get(scope, target) { return states.get(scope)?.conversations[conversationKey(target)] || {}; },
|
|
40
|
+
async set(scope, target, values) {
|
|
41
|
+
return change(scope, state => { const key = conversationKey(target); state.conversations[key] = { ...state.conversations[key], ...values }; });
|
|
42
|
+
},
|
|
43
|
+
async event(scope, id) {
|
|
44
|
+
return change(scope, state => { if (state.events[id]) return false; state.events[id] = Date.now(); return true; });
|
|
45
|
+
},
|
|
46
|
+
async close() { await queue.catch(() => {}); },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { sessionWorkState, sessionWorkOwnership } from './work-state.js';
|
|
2
|
+
import { deliveryDecisionRevision } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
3
|
+
import { pauseOrigin } from './autonomous-upgrade.js';
|
|
4
|
+
import { continuationAnalysisRevision } from './analysis-manager.js';
|
|
5
|
+
import { CONTINUATION_ANALYSIS_VERSION, CONTINUATION_ANALYSIS_STATES } from './continuation-analysis.js';
|
|
6
|
+
|
|
7
|
+
const text = (value, max = 600) => typeof value === 'string' && value.trim() ? value.slice(0, max) : null;
|
|
8
|
+
const identifier = (value, max = 256) => typeof value === 'string' && value.trim() && value.length <= max ? value : null;
|
|
9
|
+
const count = value => Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
10
|
+
const time = value => Number.isFinite(value) && value >= 0 ? value : null;
|
|
11
|
+
const object = value => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
12
|
+
const source = (value, path, max) => text(value, max) === null ? null : { text: text(value, max), source: path, truncated: value.length > (max || 600) };
|
|
13
|
+
const action = (id, label) => ({ id, label });
|
|
14
|
+
const detailHost = row => {
|
|
15
|
+
const remote = row.remoteHost != null || row.teamRemote === true;
|
|
16
|
+
const host = identifier(row.hostId);
|
|
17
|
+
if (host && !/[\u0000-\u001f\u007f]/.test(host) && (host !== 'local' || !remote)) return host;
|
|
18
|
+
return row.hostId == null && !remote ? 'local' : null;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const RECOVERY = {
|
|
22
|
+
update_recovery_required: ['The last update didn\'t finish', 'open_update_recovery', 'Open update recovery'],
|
|
23
|
+
writer_reconnect_required: ['This conversation needs to reconnect', 'open_original', 'Send one message in the original conversation'],
|
|
24
|
+
dispatch_history_quarantined: ['Earlier unconfirmed work was set aside', 'open_original', 'Open original conversation'],
|
|
25
|
+
task_admission_blocked: ['Fresh provider permissions are required', 'open_original', 'Continue once in the original conversation'],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function currentAnalysisPlan(candidate, fresh, now) {
|
|
29
|
+
const analysis = object(candidate.analysis), steps = analysis.outcomePlan?.steps;
|
|
30
|
+
if (fresh && CONTINUATION_ANALYSIS_STATES.includes(analysis.state) && analysis.analysisVersion === CONTINUATION_ANALYSIS_VERSION &&
|
|
31
|
+
analysis.revision === continuationAnalysisRevision(candidate) && !analysis.previousAnalysis && analysis.awaitingTurn === false &&
|
|
32
|
+
Number.isFinite(analysis.analyzedAt) && analysis.analyzedAt > 0 && analysis.analyzedAt <= now &&
|
|
33
|
+
analysis.stale !== true && analysis.fetchFailed !== true && Array.isArray(steps) && steps.length > 0 && steps.length <= 12 &&
|
|
34
|
+
steps.every(step => identifier(step?.id, 60) && text(step.title) && ['planned', 'current', 'reported'].includes(step.state)) &&
|
|
35
|
+
new Set(steps.map(step => step.id)).size === steps.length) {
|
|
36
|
+
const current = steps.map((step, index) => ({ step, index })).filter(({ step }) => step.state === 'current');
|
|
37
|
+
if (analysis.state === 'continue' ? current.length === 1 : current.length === 0) return { current: current[0] ?? null, steps };
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Additive, read-only native presentation. Actions are identifiers for existing
|
|
43
|
+
// routes, never grants of permission, executable commands or provider receipts.
|
|
44
|
+
export function conversationPresentation(candidate = {}, projectedRun, { rawRecord, now = Date.now() } = {}) {
|
|
45
|
+
const identity = { provider: identifier(candidate.provider, 32), session: identifier(candidate.session), hostId: detailHost(candidate) };
|
|
46
|
+
const projected = object(projectedRun);
|
|
47
|
+
// Unknown remote or malformed host provenance must never borrow a local run.
|
|
48
|
+
const matches = row => row && identity.hostId !== null && row.provider === candidate.provider && row.session === candidate.session &&
|
|
49
|
+
detailHost(row) === identity.hostId;
|
|
50
|
+
const view = matches(projected) ? projected : {};
|
|
51
|
+
const raw = matches(rawRecord) ? object(rawRecord.run) : {};
|
|
52
|
+
const rawRevision = deliveryDecisionRevision(raw);
|
|
53
|
+
// Do not join context from an earlier task/revision into a newer status frame.
|
|
54
|
+
const sameRaw = !view.runId || raw.id === view.runId &&
|
|
55
|
+
(!view.taskId || raw.managed?.taskId === view.taskId) &&
|
|
56
|
+
(view.revision === undefined || view.revision === rawRevision);
|
|
57
|
+
const recordRun = sameRaw ? raw : {};
|
|
58
|
+
const managed = object(recordRun.managed);
|
|
59
|
+
const field = name => view[name] ?? recordRun[name] ?? managed[name];
|
|
60
|
+
const ref = name => view[name] != null ? `run.${name}` : recordRun[name] != null ? `record.run.${name}` : `record.run.managed.${name}`;
|
|
61
|
+
const run = { ...recordRun, ...view, ...(Object.keys(managed).length ? { managed } : {}) };
|
|
62
|
+
const work = candidate.workState || sessionWorkState(candidate, run, { now });
|
|
63
|
+
const ownership = work.ownership || sessionWorkOwnership(work, candidate, run).ownership;
|
|
64
|
+
const target = { ...identity, runId: identifier(view.runId ?? recordRun.id),
|
|
65
|
+
taskId: identifier(view.taskId ?? managed.taskId), revision: count(view.revision ?? rawRevision) };
|
|
66
|
+
const reason = source(candidate.autoContinueReason, 'candidate.autoContinueReason') || source(field('reason'), ref('reason'));
|
|
67
|
+
const failures = count(field('failures'));
|
|
68
|
+
const maxFailures = count(field('maxFailures'));
|
|
69
|
+
const pause = pauseOrigin(run);
|
|
70
|
+
const stopCode = text(view.stopCode ?? recordRun.stopReason?.code, 64);
|
|
71
|
+
const executionObserved = work.state === 'running' && !work.stale && !work.fetchFailed;
|
|
72
|
+
const readiness = (state, tone, line, nextAction = null) => ({ state, tone, text: line, action: nextAction,
|
|
73
|
+
reason, source: 'derived', savedOn: candidate.autoContinue === true, providerExecutionConfirmed: executionObserved });
|
|
74
|
+
let ready;
|
|
75
|
+
let recovery = null;
|
|
76
|
+
const blocker = object(candidate.dispatchReadiness);
|
|
77
|
+
const state = field('state');
|
|
78
|
+
const stopped = state === 'stopped' || state === 'paused' && pause === 'user' || work.state === 'stopped';
|
|
79
|
+
const terminal = ['completed', 'reported_done'].includes(state) || ['completed', 'idle', 'archived'].includes(work.state);
|
|
80
|
+
const control = candidate.provider === 'codex' &&
|
|
81
|
+
(view.capability === 'codex_app_server' || managed.resumeChannel === 'codex_app_server');
|
|
82
|
+
const pendingDecision = ['approval', 'question'].includes(work.state) || field('pendingApproval') && !field('pendingApproval').denied ||
|
|
83
|
+
field('understandingQuestion') || managed.userQuestion;
|
|
84
|
+
if (candidate.autoContinueStatus === 'save_failed') {
|
|
85
|
+
ready = readiness('save_failed', 'danger', reason?.text || 'Could not save Auto Continue. Retry.');
|
|
86
|
+
} else if (candidate.autoContinueStatus === 'saving') {
|
|
87
|
+
ready = readiness('saving', 'accent', 'Saving On…');
|
|
88
|
+
} else if (candidate.autoContinue !== true) {
|
|
89
|
+
ready = readiness('off', 'secondary', "Off. SIMY watches but doesn't send.");
|
|
90
|
+
} else if (blocker.state === 'blocked' && !terminal) {
|
|
91
|
+
const code = text(blocker.code, 64) || 'unknown';
|
|
92
|
+
const [title, id, label] = RECOVERY[code] || ['SIMY cannot send work yet', 'open_original', 'Open original conversation'];
|
|
93
|
+
const detail = source(blocker.reason, 'candidate.dispatchReadiness.reason') || reason;
|
|
94
|
+
recovery = { code, title, reason: detail, action: action(id, label),
|
|
95
|
+
preservation: 'Your goal, history and failure budget are kept.', resendAllowed: false };
|
|
96
|
+
ready = readiness('blocked', 'attention', `Can't send yet: ${detail?.text || title}`, recovery.action);
|
|
97
|
+
} else if (stopCode === 'failure_limit' || maxFailures !== null && maxFailures > 0 && failures !== null && failures >= maxFailures) {
|
|
98
|
+
ready = readiness('budget_exhausted', 'danger', failures === null ? reason?.text || 'Stopped after repeated failed attempts.' : `Stopped after ${failures} failed attempts.`, action('open_original', 'Open original conversation'));
|
|
99
|
+
} else if (state === 'budget_exhausted' || stopCode === 'purpose_budget') {
|
|
100
|
+
ready = readiness('budget_exhausted', 'danger', reason?.text || 'The original continuation budget is exhausted.', action('open_original', 'Open original conversation'));
|
|
101
|
+
} else if (stopped) {
|
|
102
|
+
ready = readiness('paused_user', 'secondary', field('canResume') === false ? 'Paused by you. Resume is unavailable; check the original conversation.' : 'Paused by you. Resume to continue.',
|
|
103
|
+
field('canResume') === false ? action('open_original', 'Open original conversation') : action('delivery_resume', 'Resume'));
|
|
104
|
+
} else if (state === 'paused' && (pause === 'system' || ['no_progress_time', 'no_progress_attempts'].includes(stopCode))) {
|
|
105
|
+
const minutes = count(view.progressBudget?.minutes ?? managed.maxNoProgressMinutes);
|
|
106
|
+
ready = readiness('paused_rule', 'attention', `${minutes !== null && stopCode !== 'no_progress_attempts' ? `Paused: no progress for ${minutes} min.` : reason?.text || 'Paused by a progress rule.'} ${field('canResume') === false ? 'Resume is unavailable; check the original conversation.' : 'Resume restarts the progress window; failures are kept.'}`,
|
|
107
|
+
field('canResume') === false ? action('open_original', 'Open original conversation') : action('delivery_resume', 'Resume'));
|
|
108
|
+
} else if (work.state === 'running' && !work.stale && !work.fetchFailed) {
|
|
109
|
+
ready = { ...readiness('provider_running', 'secondary', 'Waiting for the current turn to finish'), providerExecutionConfirmed: true };
|
|
110
|
+
} else if (pendingDecision) {
|
|
111
|
+
ready = readiness('decision_required', 'attention', work.state === 'approval' ? 'Waiting for your approval' : 'Waiting for your answer', action('review_decision', 'Review decision'));
|
|
112
|
+
} else if (work.state === 'family_unfinished') {
|
|
113
|
+
ready = readiness('family_unfinished', ownership === 'needs_you' ? 'attention' : 'secondary', 'Related conversations have unfinished work.', action('review_children', 'Review related conversations'));
|
|
114
|
+
} else if (state === 'verification_pending' && ownership === 'needs_you') {
|
|
115
|
+
ready = readiness('acceptance_required', 'attention', 'Waiting for your check', action('review_result', 'Check result'));
|
|
116
|
+
} else if (terminal) {
|
|
117
|
+
ready = readiness('finished', 'secondary', state === 'reported_done' ? 'The AI reported completion; verification is pending.' : 'No current work to continue.');
|
|
118
|
+
} else if (candidate.provider === 'claude' && candidate.isRunning === false && !['starting', 'recovering'].includes(candidate.autoContinueStatus)) {
|
|
119
|
+
ready = readiness('original_turn_required', 'attention', 'Send one message in the original conversation. Auto Continue waits for its next verified hook.', action('open_original', 'Open original conversation'));
|
|
120
|
+
} else if (control && candidate.autoContinueStatus === 'supervising' && ['active', 'verification_pending'].includes(state) &&
|
|
121
|
+
ownership === 'simy_continues' && blocker.state === 'ready' &&
|
|
122
|
+
!field('executionUncertain') && !field('providerWarning') && !managed.nativeWait && !managed.purpose?.nativeBoundary) {
|
|
123
|
+
ready = readiness('ready', 'accent', 'Will continue after this turn');
|
|
124
|
+
} else if (['starting', 'recovering'].includes(candidate.autoContinueStatus)) {
|
|
125
|
+
ready = readiness(candidate.autoContinueStatus, 'accent', reason?.text || 'Checking Auto Continue readiness.');
|
|
126
|
+
} else {
|
|
127
|
+
ready = readiness('waiting', 'secondary', reason?.text || 'Checking whether the next step can be sent.', action('refresh', 'Refresh status'));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const nextValue = field('nextStep');
|
|
131
|
+
const checkpoint = object(view.reportedCheckpoint ?? recordRun.checkpoint);
|
|
132
|
+
const pendingManual = Array.isArray(view.pendingAcceptance) ? view.pendingAcceptance : [];
|
|
133
|
+
const manualIndex = pendingManual.findIndex(item => item?.check === 'manual');
|
|
134
|
+
const criteria = Array.isArray(managed.acceptanceCriteria) ? managed.acceptanceCriteria : [];
|
|
135
|
+
const rawManualIndex = criteria.findIndex(item => item?.check?.type === 'manual' && !Object.hasOwn(managed.verified || {}, item.id));
|
|
136
|
+
const manual = manualIndex >= 0 ? source(pendingManual[manualIndex].description, `run.pendingAcceptance[${manualIndex}].description`) :
|
|
137
|
+
rawManualIndex >= 0 ? source(criteria[rawManualIndex].description, `record.run.managed.acceptanceCriteria[${rawManualIndex}].description`) : null;
|
|
138
|
+
const decision = source(candidate.decisionRequest?.question, 'candidate.decisionRequest.question') ||
|
|
139
|
+
source(field('pendingApproval')?.summary, `${ref('pendingApproval')}.summary`) ||
|
|
140
|
+
source(field('understandingQuestion')?.text, `${ref('understandingQuestion')}.text`) ||
|
|
141
|
+
source(managed.userQuestion?.text, 'record.run.managed.userQuestion.text');
|
|
142
|
+
const next = work.state === 'family_unfinished' ? source(work.reason, 'candidate.workState.reason') :
|
|
143
|
+
ready.state === 'acceptance_required' ? manual : pendingDecision ? decision :
|
|
144
|
+
source(nextValue, ref('nextStep'), 600) || source(checkpoint.nextStep, view.reportedCheckpoint ? 'run.reportedCheckpoint.nextStep' : 'record.run.checkpoint.nextStep', 600);
|
|
145
|
+
const userAction = ready.action && ['blocked', 'paused_user', 'paused_rule', 'budget_exhausted', 'original_turn_required', 'decision_required', 'acceptance_required'].includes(ready.state) ||
|
|
146
|
+
ready.state === 'family_unfinished' && ownership === 'needs_you';
|
|
147
|
+
const nextOwner = userAction ? 'you' : ready.state === 'ready' || ['starting', 'recovering'].includes(ready.state) ? 'simy' :
|
|
148
|
+
ready.state === 'provider_running' ? 'ai' : 'unknown';
|
|
149
|
+
// Supporting copy never supplies execution authority or invents a plan.
|
|
150
|
+
// Older/in-flight analyses and unbound decision presentations stay absent.
|
|
151
|
+
let nextDetail = null;
|
|
152
|
+
let nextCopy;
|
|
153
|
+
const knownHost = detailHost(candidate);
|
|
154
|
+
const detailFresh = knownHost !== null &&
|
|
155
|
+
work.stale !== true && work.fetchFailed !== true && !['unknown', 'family_unfinished'].includes(work.state);
|
|
156
|
+
const analysis = object(candidate.analysis);
|
|
157
|
+
const currentPlan = currentAnalysisPlan(candidate, detailFresh, now);
|
|
158
|
+
const plan = { current: currentPlan !== null, currentStepId: currentPlan?.current?.step.id ?? null,
|
|
159
|
+
source: 'candidate.analysis.outcomePlan', analysisRevision: identifier(analysis.revision),
|
|
160
|
+
analysisVersion: count(analysis.analysisVersion), analyzedAt: time(analysis.analyzedAt), analysisState: text(analysis.state, 32) };
|
|
161
|
+
if (detailFresh && pendingDecision && next?.source === 'candidate.decisionRequest.question') {
|
|
162
|
+
const request = object(candidate.decisionRequest);
|
|
163
|
+
const bindings = [request, object(request.fields), object(request.target), object(request.answerFields),
|
|
164
|
+
...(Array.isArray(request.choices) ? request.choices.map(choice => object(choice?.fields)) : [])];
|
|
165
|
+
const pending = request.kind === 'approval' ? object(field('pendingApproval')) : request.kind === 'question' ? object(field('understandingQuestion') ?? managed.userQuestion) : {};
|
|
166
|
+
const bound = bindings.filter(binding => binding.provider === identity.provider && binding.session === identity.session &&
|
|
167
|
+
detailHost(binding) === knownHost && target.runId && target.taskId && target.revision !== null &&
|
|
168
|
+
binding.expectedRunId === target.runId && binding.taskId === target.taskId && binding.expectedRevision === target.revision && pending.id &&
|
|
169
|
+
binding[request.kind === 'approval' ? 'approvalId' : 'questionId'] === pending.id);
|
|
170
|
+
if (bound.length && request.stale !== true && request.fetchFailed !== true) {
|
|
171
|
+
// Reuse the typed decision's literal locale copy only after its existing
|
|
172
|
+
// host/run/task/revision/proposal fence. Never translate provider text.
|
|
173
|
+
const localized = Object.fromEntries(['ja', 'en'].flatMap(locale => {
|
|
174
|
+
const question = source(request.copy?.[locale]?.question, 'candidate.decisionRequest.copy');
|
|
175
|
+
return question ? [[locale, { text: question.text }]] : [];
|
|
176
|
+
}));
|
|
177
|
+
if (Object.keys(localized).length) nextCopy = localized;
|
|
178
|
+
nextDetail = source(request.reason, 'candidate.decisionRequest.reason') || source(request.explanation, 'candidate.decisionRequest.explanation');
|
|
179
|
+
const approval = object(field('pendingApproval'));
|
|
180
|
+
if (!nextDetail && approval.id && bound.some(binding => binding.approvalId === approval.id)) {
|
|
181
|
+
nextDetail = source(approval.reason, `${ref('pendingApproval')}.reason`);
|
|
182
|
+
const assessment = object(field('decision'));
|
|
183
|
+
if (!nextDetail && assessment.id === approval.id && ['kind', 'scope', 'summary'].every(key =>
|
|
184
|
+
typeof approval[key] === 'string' && assessment.action?.[key] === approval[key])) nextDetail = source(assessment.reason, `${ref('decision')}.reason`);
|
|
185
|
+
}
|
|
186
|
+
if (nextDetail) nextDetail = { kind: 'decision_reason', ...nextDetail };
|
|
187
|
+
}
|
|
188
|
+
} else if (detailFresh && !pendingDecision && ready.state !== 'acceptance_required' && next?.text) {
|
|
189
|
+
if (analysis.state === 'continue' && currentPlan?.current && currentPlan.current.step.title === analysis.nextAction && analysis.nextAction === next.text) {
|
|
190
|
+
const stepIndex = currentPlan.current.index + 1, stepCount = currentPlan.steps.length;
|
|
191
|
+
nextDetail = { kind: 'plan_position', text: `Step ${stepIndex} of ${stepCount} in this plan.`,
|
|
192
|
+
source: 'candidate.analysis.outcomePlan.steps', truncated: false, stepIndex, stepCount };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const history = Array.isArray(view.reportedWork) ? view.reportedWork : [];
|
|
196
|
+
const rawHistory = Array.isArray(managed.workHistory) ? managed.workHistory : [];
|
|
197
|
+
const lastRaw = rawHistory.findLast(item => ['in_progress', 'completed'].includes(item?.status) && item.result?.status === 'observed');
|
|
198
|
+
const checkpointSource = Object.keys(checkpoint).length ? view.reportedCheckpoint ? 'run.reportedCheckpoint.summary' : 'record.run.checkpoint.summary' : null;
|
|
199
|
+
const last = source(checkpoint.summary, checkpointSource) || source(history.at(-1), `run.reportedWork[${history.length - 1}]`) ||
|
|
200
|
+
source(lastRaw?.result?.summary || lastRaw?.summary, 'record.run.managed.workHistory');
|
|
201
|
+
return { schemaVersion: 1, target,
|
|
202
|
+
readiness: ready, recovery, plan,
|
|
203
|
+
now: { state: text(work.state, 64), ownership, label: text(work.label, 200),
|
|
204
|
+
reason: source(work.reason, 'candidate.workState.reason'), stale: work.stale === true, checkedAt: time(work.checkedAt), ownershipObservedAt: time(work.ownershipObservedAt), providerExecutionConfirmed: executionObserved },
|
|
205
|
+
next: { ...(next || { text: null, source: null, truncated: false }), ...(nextCopy ? { copy: nextCopy } : {}), owner: nextOwner,
|
|
206
|
+
executable: ready.state === 'ready' && Boolean(next), action: ready.action, detail: nextDetail },
|
|
207
|
+
last: { ...(last || { text: null, source: null, truncated: false }), reportedBy: last ? 'ai' : null,
|
|
208
|
+
verified: false, status: text(checkpoint.status ?? lastRaw?.status, 32), label: last ? 'Reported by AI · not verified' : null,
|
|
209
|
+
at: last ? time(view.checkpointAt ?? recordRun.checkpoint?.at ?? lastRaw?.at) : null },
|
|
210
|
+
pause: { origin: stopped ? 'user' : state === 'paused' ? pause : null, stopCode, failures, maxFailures,
|
|
211
|
+
progressMinutes: count(view.progressBudget?.minutes ?? managed.maxNoProgressMinutes) } };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Cached heartbeats may outlive the fresh observation they originally carried.
|
|
215
|
+
// Expire only the live claim; retain bounded last/next context and provenance.
|
|
216
|
+
export function expireConversationPresentation(candidate) {
|
|
217
|
+
let previous = candidate.conversationPresentation;
|
|
218
|
+
const work = candidate.workState;
|
|
219
|
+
if ((previous?.next?.detail || previous?.next?.copy || previous?.plan?.current) && (!work || work.stale === true || work.fetchFailed === true || ['unknown', 'family_unfinished'].includes(work.state))) {
|
|
220
|
+
const next = { ...previous.next, detail: null };
|
|
221
|
+
delete next.copy;
|
|
222
|
+
previous = { ...previous, next,
|
|
223
|
+
...(previous.plan ? { plan: { ...previous.plan, current: false, currentStepId: null } } : {}) };
|
|
224
|
+
candidate = { ...candidate, conversationPresentation: previous };
|
|
225
|
+
}
|
|
226
|
+
if (previous && work?.state === 'family_unfinished' &&
|
|
227
|
+
(previous.now.ownership !== work.ownership || previous.now.stale !== (work.stale === true))) {
|
|
228
|
+
return { ...candidate, conversationPresentation: { ...previous,
|
|
229
|
+
readiness: { ...previous.readiness, tone: work.ownership === 'needs_you' ? 'attention' : 'secondary', providerExecutionConfirmed: false },
|
|
230
|
+
now: { ...previous.now, ownership: work.ownership, stale: work.stale === true,
|
|
231
|
+
ownershipObservedAt: time(work.ownershipObservedAt), providerExecutionConfirmed: false },
|
|
232
|
+
next: { ...previous.next, owner: work.ownership === 'needs_you' ? 'you' : 'unknown', executable: false } } };
|
|
233
|
+
}
|
|
234
|
+
if (!previous || previous.readiness.state !== 'provider_running' || candidate.workState?.state === 'running' && !candidate.workState.stale) return candidate;
|
|
235
|
+
return { ...candidate, conversationPresentation: { ...previous,
|
|
236
|
+
readiness: { ...previous.readiness, state: 'waiting', tone: 'secondary', text: 'Checking whether the next step can be sent.',
|
|
237
|
+
providerExecutionConfirmed: false, action: action('refresh', 'Refresh status') },
|
|
238
|
+
now: { ...previous.now, state: candidate.workState?.state || 'unknown', ownership: candidate.workState?.ownership || 'waiting',
|
|
239
|
+
reason: source(candidate.workState?.reason, 'candidate.workState.reason'), stale: true, providerExecutionConfirmed: false },
|
|
240
|
+
next: { ...previous.next, owner: 'unknown', executable: false } } };
|
|
241
|
+
}
|
|
@@ -1,54 +1,115 @@
|
|
|
1
1
|
const clean = value => typeof value === 'string' ? value.trim().slice(0, 2000) : '';
|
|
2
|
+
const pair = (ja, en) => ({ ja, en });
|
|
3
|
+
const sameAction = (a, b) => ['kind', 'scope', 'summary'].every(key => typeof a?.[key] === 'string' && a[key] === b?.[key]);
|
|
4
|
+
|
|
5
|
+
// Only structured, exact-action evidence describes irreversibility. A summary
|
|
6
|
+
// containing "delete" is not a risk assessment, and another action's assessment
|
|
7
|
+
// must never colour the pending proposal or alter its authorization boundary.
|
|
8
|
+
export function approvalEffect(approval, decision) {
|
|
9
|
+
const assessment = decision?.id === approval?.id && sameAction(approval, decision.action) ? decision.assessment : null;
|
|
10
|
+
if (approval?.kind === 'destructive' || assessment?.reversibility === 'irreversible') return 'irreversible';
|
|
11
|
+
if (assessment?.reversibility === 'reversible') return 'reversible';
|
|
12
|
+
return 'unknown';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Typed copy is additive for older hosts. Locale changes select these fields;
|
|
16
|
+
// they never translate arbitrary provider text or change a command payload.
|
|
17
|
+
function text(record, copy) {
|
|
18
|
+
const localized = { ja: {}, en: {} };
|
|
19
|
+
for (const [key, values] of Object.entries(copy)) {
|
|
20
|
+
record[key] = values.ja;
|
|
21
|
+
localized.ja[key] = values.ja; localized.en[key] = values.en;
|
|
22
|
+
}
|
|
23
|
+
return { ...record, copy: localized };
|
|
24
|
+
}
|
|
2
25
|
|
|
3
26
|
// The presentation never invents consent or executes a choice. Commands still
|
|
4
27
|
// validate the exact live proposal, question, run identity and revision.
|
|
5
28
|
export function sessionDecisionPresentation(candidate, run) {
|
|
6
|
-
if (!candidate || candidate.
|
|
29
|
+
if (!candidate || candidate.archived) return null;
|
|
7
30
|
const identity = { provider: candidate.provider, session: candidate.session };
|
|
8
31
|
const control = { ...identity, ...(run?.taskId ? { taskId: run.taskId } : {}),
|
|
9
32
|
...(run?.runId ? { expectedRunId: run.runId, expectedRevision: run.revision } : {}) };
|
|
10
|
-
const choice = (label, effect, action, fields = {}) => ({
|
|
33
|
+
const choice = (label, effect, action, fields = {}, role = 'secondary') => text({ action, role, fields: { ...control, ...fields } }, { label, effect });
|
|
34
|
+
const model = (kind, copy, fields = {}, presentation = 'decision') => text({ kind, presentation, ...fields }, copy);
|
|
11
35
|
const frozen = typeof run?.runId === 'string' && run.runId.length > 0 && Number.isSafeInteger(run.revision) && run.revision >= 0;
|
|
12
|
-
const refreshDecision = () => (
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
36
|
+
const refreshDecision = () => model('unresolved', {
|
|
37
|
+
title: pair('判断の状態を再確認します', 'Refresh the decision state'),
|
|
38
|
+
question: pair('現在の作業と判断の対応を確認できません。最新の状態を取得してから選んでください。', 'This decision cannot be matched to the current work. Refresh before choosing.'),
|
|
39
|
+
context: pair('この確認だけでは操作を許可したり、作業を再開したりしません。', 'Refreshing does not grant permission or resume work.'),
|
|
40
|
+
}, { choices: [choice(pair('状況を再確認する', 'Refresh status'), pair('最新の状態を取得します。許可・停止状態は変更しません。', 'Get the latest state without changing permissions or stops.'), 'refresh', identity)] }, 'helper');
|
|
41
|
+
if (run?.state === 'blocked' && run.providerRequest?.source === 'provider_hook') return model('unresolved', {
|
|
42
|
+
title: run.providerRequest.kind === 'question'
|
|
43
|
+
? pair('元のAIが回答を待っています', 'The original AI is waiting for an answer')
|
|
44
|
+
: pair('元のAIが権限の確認を待っています', 'The original AI is waiting for a permission decision'),
|
|
45
|
+
question: pair('元の会話で現在の要求を確認してください。', 'Check the current request in the original conversation.'),
|
|
46
|
+
context: pair('この画面ではproviderの権限要求を代わりに承認しません。', 'This screen does not approve provider permission requests.'),
|
|
47
|
+
}, { choices: [choice(pair('元の会話を開く', 'Open original conversation ↗'), pair('現在の要求を元のAIで確認します。', 'Check the current request in the original AI conversation.'), 'open_original')] }, 'helper');
|
|
16
48
|
const approval = run?.pendingApproval;
|
|
17
49
|
if (approval?.id && clean(approval.summary) && !approval.denied && run.canApprove && !frozen) return refreshDecision();
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
50
|
+
// A separate AI turn may still be running while this exact managed proposal
|
|
51
|
+
// waits for consent. Only execution/resume choices are suppressed by that turn.
|
|
52
|
+
if (approval?.id && clean(approval.summary) && !approval.denied && run.canApprove) {
|
|
53
|
+
const effect = approvalEffect(approval, run.decision);
|
|
54
|
+
const scope = ({ task: pair('この依頼の範囲', 'this task'), repository: pair('リポジトリ', 'repository') })[approval.scope]
|
|
55
|
+
|| pair(clean(approval.scope) || '依頼された操作の範囲', clean(approval.scope) || 'the requested operation');
|
|
56
|
+
const effectCopy = ({ irreversible: pair('元に戻せません', 'irreversible'), reversible: pair('元に戻せます', 'reversible'), unknown: pair('復元可能性は未確認', 'reversibility not established') })[effect];
|
|
57
|
+
const why = clean(approval.reason) || (run.decision?.id === approval.id && sameAction(approval, run.decision?.action) ? clean(run.decision.reason) : '');
|
|
58
|
+
return model('approval', {
|
|
59
|
+
title: pair(`承認:${clean(approval.summary)}`, `Approve: ${clean(approval.summary)}`),
|
|
60
|
+
question: pair(`実行する操作:${clean(approval.summary)}`, `Operation: ${clean(approval.summary)}`),
|
|
61
|
+
scope: pair(`対象:${scope.ja} · 効果:${effectCopy.ja}`, `Scope: ${scope.en} · Effect: ${effectCopy.en}`),
|
|
62
|
+
context: pair(why ? `理由:${why}` : '表示された対象・操作だけを許可します。', why ? `Why: ${why}` : 'Permission applies only to the displayed operation and scope.'),
|
|
63
|
+
}, { effect, choices: [
|
|
64
|
+
choice(pair('この操作だけを許可', 'Approve'), pair('表示された対象・操作だけを許可します。AI側で権限確認が必要な場合は別途案内します。', 'Allow only this operation and scope. Any provider permission is handled separately.'), 'delivery_approve', { approvalId: approval.id }, effect === 'irreversible' ? 'destructive' : effect === 'reversible' ? 'primary' : 'secondary'),
|
|
65
|
+
choice(pair('許可しない', 'Do not approve'), pair(run.canReplan ? 'この操作は実行しません。別の方法を調べる場合は「AIに状況確認を任せる」を選んでください。' : 'この操作は実行しません。別の方法が必要な場合は、元のAI会話で確認してください。', run.canReplan ? 'Reject this operation. Ask AI to investigate if you need another approach.' : 'Reject this operation. Discuss another approach in the original conversation.'), 'delivery_reject', { approvalId: approval.id }),
|
|
66
|
+
choice(pair('元の会話を開く', 'Open original conversation ↗'), pair('会話を開くだけでは承認や再開をしません。', 'Opening the conversation does not approve or resume work.'), 'open_original', {}, 'link'),
|
|
67
|
+
] });
|
|
68
|
+
}
|
|
24
69
|
const question = run?.understandingQuestion;
|
|
25
70
|
if (run?.canAnswer && question?.id && clean(question.text) && !frozen) return refreshDecision();
|
|
26
|
-
if (run?.canAnswer && question?.id && clean(question.text)) return {
|
|
27
|
-
|
|
28
|
-
context: '回答を受け取ってから目的を確認し、許可された作業を続けます。',
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
};
|
|
32
|
-
if (run?.dependencyWait || run?.autoRecovery && ['recovery_required', 'auto_resuming'].includes(run.state) ||
|
|
71
|
+
if (run?.canAnswer && question?.id && clean(question.text)) return model('question', {
|
|
72
|
+
title: pair('続けるために、この点を教えてください', 'Answer to continue'), question: pair(clean(question.text), clean(question.text)),
|
|
73
|
+
context: pair('回答を受け取ってから目的を確認し、許可された作業を続けます。', 'After your answer, AI checks the goal and continues within the existing permissions.'),
|
|
74
|
+
}, { answerFields: { ...control, taskId: run.taskId, questionId: question.id }, choices: [] });
|
|
75
|
+
if (candidate.isRunning || run?.dependencyWait || run?.autoRecovery && ['recovery_required', 'auto_resuming'].includes(run.state) ||
|
|
33
76
|
['supervising', 'watching', 'waiting', 'starting', 'recovering'].includes(candidate.autoContinueStatus)) return null;
|
|
34
|
-
if (run && ['recovery_required', 'paused', 'failed', 'blocked'].includes(run.state)) {
|
|
77
|
+
if (run && (['recovery_required', 'paused', 'failed', 'blocked'].includes(run.state) || run.state === 'verification_pending' && run.canResume)) {
|
|
78
|
+
const acceptancePending = run.state === 'verification_pending';
|
|
35
79
|
const providerName = candidate.provider === 'claude' ? 'Claude' : candidate.provider === 'codex' ? 'Codex' : 'AI';
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
80
|
+
const reasons = {
|
|
81
|
+
session_end: pair(`元の${providerName}会話が終了し、自動継続が止まっています。`, `The original ${providerName} session ended, stopping Auto Continue.`),
|
|
82
|
+
process_exit: pair('元のAIが終了したため、自動継続できていません。', 'The original AI process exited. Auto Continue cannot proceed.'),
|
|
83
|
+
provider_error: pair('AIの実行でエラーが発生し、作業が止まっています。', 'An AI execution error stopped the work.'),
|
|
84
|
+
shutdown_cancelled: pair('SIMYの終了時に作業が中断されました。', 'Work was interrupted when SIMY exited.'),
|
|
85
|
+
hook_owner_mismatch: pair('元のAI会話の接続が変わりました。', 'The connection to the original AI session changed.'),
|
|
86
|
+
};
|
|
87
|
+
const reason = acceptancePending ? pair('最終確認は未完了ですが、許可済みの残作業を続けられます。再開しても検証済みにはなりません。', 'Acceptance is still pending. Authorized remaining work can continue without marking the result verified.')
|
|
88
|
+
: run.state === 'paused' ? pair('この作業は停止中です。停止の指示は自動で解除しません。', 'This work is paused. A stop instruction is never cleared automatically.')
|
|
89
|
+
: reasons[run.recoveryCause] || pair('作業を続けるための条件を確認できていません。', 'The conditions for continuing have not been established.');
|
|
40
90
|
const choices = [];
|
|
41
|
-
if (run.canReplan && frozen) choices.push(choice('AIに状況確認を任せる', '目的・履歴・制限を維持して原因と安全な次の手順を調べます。操作の許可を追加することはありません。', 'delivery_replan'));
|
|
42
|
-
if (run.canResume && frozen) choices.push(choice('再開を依頼する', '保存した目的と履歴を引き継ぎます。接続や権限が不足する場合は、その内容を案内します。', 'delivery_resume'));
|
|
43
|
-
choices.push(choice(`元の${
|
|
44
|
-
if (candidate.autoContinue === true) choices.push(choice('自動継続をオフにする', '今後の自動継続をオフにします。作業の履歴は残ります。', 'session_set_auto_continue', { ...identity, enabled: false }));
|
|
45
|
-
|
|
91
|
+
if (run.canReplan && frozen) choices.push(choice(pair('AIに状況確認を任せる', 'Ask AI to investigate'), pair('目的・履歴・制限を維持して原因と安全な次の手順を調べます。操作の許可を追加することはありません。', 'Investigate the cause and next steps while keeping the goal, history and limits. No new permission is granted.'), 'delivery_replan', {}, 'primary'));
|
|
92
|
+
if (run.canResume && frozen) choices.push(choice(pair(acceptancePending ? '未完了の作業を続ける' : '再開を依頼する', acceptancePending ? 'Continue remaining work' : 'Request resume'), pair(acceptancePending ? '元の目的・履歴・制限を維持して残作業を確認します。最終確認を済ませたことにはしません。' : '保存した目的と履歴を引き継ぎます。接続や権限が不足する場合は、その内容を案内します。', acceptancePending ? 'Keep the goal, history and limits while checking what remains. This does not accept the result.' : 'Keep the saved goal and history. Any missing connection or permission will be shown.'), 'delivery_resume', {}, choices.length ? 'secondary' : 'primary'));
|
|
93
|
+
choices.push(choice(pair(`元の${providerName}会話を開く`, 'Open original conversation ↗'), pair('接続や権限の確認が必要な場合、元のAI会話で確認できます。会話を開くだけでは作業を再開しません。', 'Check the original conversation. Opening it does not resume work.'), 'open_original', {}, 'link'));
|
|
94
|
+
if (candidate.autoContinue === true) choices.push(choice(pair('自動継続をオフにする', 'Turn off Auto Continue'), pair('今後の自動継続をオフにします。作業の履歴は残ります。', 'Turn off future automatic continuation and retain the history.'), 'session_set_auto_continue', { ...identity, enabled: false }));
|
|
95
|
+
const actionable = frozen && (run.canReplan || run.canResume);
|
|
96
|
+
return model('recovery', {
|
|
97
|
+
title: pair(acceptancePending ? '最終確認を待ちながら、残作業を続けられます' : '作業が止まっています。対応を選んでください', acceptancePending ? 'Continue work while acceptance is pending' : 'Work stopped. Choose how to proceed'),
|
|
98
|
+
question: pair(actionable ? run.canReplan ? '選ぶこと:AIに状況確認を任せるか、元の会話を確認するか。' : '選ぶこと:再開を依頼するか、元の会話を確認するか。' : 'この画面からの再開には対応していません。元のAI会話で状態を確認できます。', actionable ? 'Choose a recovery action or check the original conversation.' : 'This screen cannot resume the work. Check the original conversation.'),
|
|
99
|
+
context: pair(`理由:${reason.ja}`, `Reason: ${reason.en}`),
|
|
100
|
+
}, { choices }, !acceptancePending && actionable ? 'decision' : 'helper');
|
|
46
101
|
}
|
|
47
|
-
if (candidate.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
102
|
+
if (candidate.autoContinue === false && candidate.analysis?.state === 'continue' &&
|
|
103
|
+
candidate.capabilities?.canAutoContinue === true && candidate.autoContinueAvailability?.canEnable !== false) return model('continue', {
|
|
104
|
+
title: pair('続けますか?', 'Continue?'),
|
|
105
|
+
question: pair(clean(candidate.analysis.reason) || '許可済みの残作業があります。', clean(candidate.analysis.reason) || 'Authorized work remains.'),
|
|
106
|
+
context: pair('自動継続はオフです。オンにすると、元の目的と権限の範囲で残作業を確認します。結果を受け入れる操作ではありません。', 'Auto Continue is off. Turning it on checks remaining work within the original goal and permissions. It does not accept the result.'),
|
|
107
|
+
}, { choices: [choice(pair('自動継続をオンにする', 'Turn on Auto Continue'), pair('自動継続の設定を保存します。作業の開始や完了を証明するものではありません。', 'Save consent for Auto Continue. This does not confirm that work started or finished.'), 'session_set_auto_continue', { ...identity, enabled: true }, 'primary'),
|
|
108
|
+
choice(pair('元の会話を開く', 'Open original conversation ↗'), pair('会話を開くだけでは作業を再開しません。', 'Opening the conversation does not resume work.'), 'open_original', {}, 'link')] });
|
|
109
|
+
if (candidate.autoContinueStatus === 'needs_user' || candidate.analysis?.state === 'needs_user') return model('unresolved', {
|
|
110
|
+
title: pair('必要な判断内容を確認します', 'Check what needs a decision'),
|
|
111
|
+
question: pair('判断していただく内容をまだ特定できていません。具体的な内容が分かるまで、操作の許可は求めません。', 'The required decision has not been identified. No approval is requested until it is clear.'),
|
|
112
|
+
context: pair('まず会話と最新の状態を確認します。元のAIが権限の確認を求めている場合は、その会話で確認が必要です。', 'Check the conversation and latest state. Any provider permission must be handled in the original conversation.'),
|
|
113
|
+
}, { choices: [choice(pair('状況を再確認する', 'Refresh status'), pair('最新の状態を取得します。許可・停止状態は変更しません。', 'Get the latest state without changing permissions or stops.'), 'refresh', identity)] }, 'helper');
|
|
53
114
|
return null;
|
|
54
115
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { updateRecord } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
2
|
+
|
|
3
|
+
// Controller choices are context, never permission to change scope or execute
|
|
4
|
+
// a proposed deadline extension. The source needs no Web credentials to use it.
|
|
5
|
+
export function controllerExecutionContext(candidate) {
|
|
6
|
+
const binding = candidate.actionAssociation?.binding;
|
|
7
|
+
const action = binding?.action;
|
|
8
|
+
const deadline = candidate.actionDeadline;
|
|
9
|
+
return {
|
|
10
|
+
executionTaskId: candidate.executionTaskId || null,
|
|
11
|
+
actionTaskId: candidate.taskId || null,
|
|
12
|
+
bindingRevision: binding?.revision ?? null,
|
|
13
|
+
action: action ? { id: action.id, title: String(action.title || '').slice(0, 120), revision: action.revision ?? null,
|
|
14
|
+
bindingRevision: binding.revision ?? null, dueAt: action.dueAt ?? null, timeZone: action.timeZone ?? null } : null,
|
|
15
|
+
deadlineChoice: deadline?.decision ? { choice: deadline.decision.choice, revision: deadline.decision.revision,
|
|
16
|
+
requestId: deadline.decision.requestId, remoteApplied: false } : null,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export async function applyControllerExecutionContext({ root, provider, session, context, cwd, authorize = async () => {} }) {
|
|
20
|
+
if (!context || typeof context !== 'object' || Array.isArray(context) || JSON.stringify(context).length > 4096 ||
|
|
21
|
+
![context.executionTaskId, context.actionTaskId].every(value => value === null || typeof value === 'string' && value.length <= 128) ||
|
|
22
|
+
context.action !== null && (typeof context.action?.id !== 'string' || typeof context.action?.title !== 'string') ||
|
|
23
|
+
context.deadlineChoice !== null && (typeof context.deadlineChoice?.choice !== 'string' || context.deadlineChoice?.remoteApplied !== false)) throw new Error('Invalid controller execution context.');
|
|
24
|
+
await updateRecord(root, provider, session, async record => {
|
|
25
|
+
await authorize();
|
|
26
|
+
if (!record && context.executionTaskId === null && typeof cwd === 'string' && cwd) record = {schemaVersion:1,provider,session,cwd,run:null};
|
|
27
|
+
if (!record || record.schemaVersion !== 1 || record.provider !== provider || record.session !== session || (record.run?.managed?.taskId || null) !== context.executionTaskId) throw Object.assign(new Error('The execution task changed; refresh its context.'), { code: 'conflict' });
|
|
28
|
+
const previous = record.controllerExecutionContext;
|
|
29
|
+
if (previous?.actionTaskId === context.actionTaskId && Number.isSafeInteger(previous.bindingRevision) &&
|
|
30
|
+
(!Number.isSafeInteger(context.bindingRevision) || previous.bindingRevision > context.bindingRevision)) throw Object.assign(new Error('A newer Action choice has already reached this task.'), { code: 'conflict' });
|
|
31
|
+
if (previous?.actionTaskId === context.actionTaskId && previous.bindingRevision === context.bindingRevision &&
|
|
32
|
+
previous.action?.id === context.action?.id && previous.action?.revision === context.action?.revision &&
|
|
33
|
+
Number.isSafeInteger(previous.deadlineChoice?.revision) && Number.isSafeInteger(context.deadlineChoice?.revision) &&
|
|
34
|
+
previous.deadlineChoice.revision > context.deadlineChoice.revision) throw Object.assign(new Error('A newer deadline choice has already reached this task.'), { code: 'conflict' });
|
|
35
|
+
record.controllerExecutionContext = structuredClone(context);
|
|
36
|
+
if (record.run?.managed) record.run.managed.controllerExecutionContext = structuredClone(context);
|
|
37
|
+
return record;
|
|
38
|
+
});
|
|
39
|
+
}
|