@awak-app/simy-cli 0.7.35 → 0.7.37

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.
Files changed (35) hide show
  1. package/README.md +50 -46
  2. package/package.json +1 -1
  3. package/src/agent.js +1 -1
  4. package/src/backend-executable.js +1 -1
  5. package/src/cli-contract.js +1 -1
  6. package/src/cli-observability.js +1 -1
  7. package/src/codex-plugins/simy/.codex-plugin/plugin.json +5 -5
  8. package/src/codex-plugins/simy/hooks/README.md +3 -3
  9. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +2 -2
  10. package/src/codex-plugins/simy/skills/continue/SKILL.md +4 -4
  11. package/src/codex-plugins/simy/skills/continue/agents/openai.yaml +3 -3
  12. package/src/codex-plugins/simy/skills/diagnose/SKILL.md +2 -2
  13. package/src/codex-plugins/simy/skills/sqm-loop/SKILL.md +3 -3
  14. package/src/codex-plugins/simy/skills/sqm-loop/agents/openai.yaml +2 -2
  15. package/src/console/app.js +7 -7
  16. package/src/delivery-hooks/claude-workflow.md +1 -1
  17. package/src/delivery-hooks/command.js +5 -5
  18. package/src/desktop-host.js +58 -11
  19. package/src/index.js +1 -1
  20. package/src/local-task.js +1 -1
  21. package/src/orchestrator/presentation.js +1 -1
  22. package/src/runner.js +2 -2
  23. package/src/session-supervision/automatic-continuation.js +11 -11
  24. package/src/session-supervision/continuation-analysis.js +1 -1
  25. package/src/session-supervision/continuation-command.js +3 -3
  26. package/src/session-supervision/continuation-readiness.js +1 -1
  27. package/src/session-supervision/conversation-presentation.js +3 -3
  28. package/src/session-supervision/decision-presentation.js +8 -8
  29. package/src/session-supervision/index.js +13 -13
  30. package/src/session-supervision/owner-transport.js +53 -11
  31. package/src/session-supervision/remote-errors.js +2 -2
  32. package/src/session-supervision/remote-hosts.js +14 -3
  33. package/src/session-supervision/work-state.js +3 -3
  34. package/src/sqm/agent-skill.js +3 -3
  35. package/src/sqm/claude-plugin.js +1 -1
@@ -12,10 +12,10 @@ export const ownerSocketPath = (root, platform = process.platform) => platform =
12
12
  const socketPath = ownerSocketPath;
13
13
  // The socket and nonce are accessible only to the local OS account. Application
14
14
  // account/provider checks still run in the owner's ordinary command handler.
15
- export async function createOwnerTransport({ root, nonce, allowedActions, dispatch, allowControllerArchive = false }) {
15
+ export async function createOwnerTransport({ root, nonce, allowedActions, dispatch, allowControllerArchive = false, allowDesktopClient = false }) {
16
16
  const writers = new WeakMap();
17
- const peers = new Set(), subscribers = new Set(), requests = new Map();
18
- let latest;
17
+ const peers = new Set(), subscribers = new Set(), desktopSubscribers = new Set(), requests = new Map();
18
+ let latest, latestDesktop;
19
19
  const send = (peer, frame) => {
20
20
  if (peer.destroyed) return;
21
21
  // A valid full-history status can already occupy up to 64 MiB of the
@@ -28,10 +28,10 @@ export async function createOwnerTransport({ root, nonce, allowedActions, dispat
28
28
  peer.setEncoding('utf8');
29
29
  const writer = createDesktopFrameWriter(peer);
30
30
  writers.set(peer, writer);
31
- let authenticated = false, controllerArchive = false, buffer = '';
31
+ let authenticated = false, controllerArchive = false, desktopClient = false, buffer = '';
32
32
  const timeout = setTimeout(() => peer.destroy(), 5000);
33
33
  peer.on('error', () => {});
34
- peer.on('close', () => { writer.close(); clearTimeout(timeout); peers.delete(peer); subscribers.delete(peer); for (const [id, item] of requests) if (item.peer === peer) requests.delete(id); });
34
+ peer.on('close', () => { writer.close(); clearTimeout(timeout); peers.delete(peer); subscribers.delete(peer); desktopSubscribers.delete(peer); for (const [id, item] of requests) if (item.peer === peer) requests.delete(id); });
35
35
  peer.on('data', chunk => {
36
36
  buffer += chunk.toString();
37
37
  if (buffer.length > 16384) { peer.destroy(); return; }
@@ -42,16 +42,18 @@ export async function createOwnerTransport({ root, nonce, allowedActions, dispat
42
42
  if (!command || typeof command !== 'object' || Array.isArray(command)) { peer.destroy(); return; }
43
43
  if (!authenticated) {
44
44
  if (command.nonce !== nonce) { peer.destroy(); return; }
45
- authenticated = true; controllerArchive = allowControllerArchive && command.client === 'archive-controller'; clearTimeout(timeout); peers.add(peer);
45
+ authenticated = true; desktopClient = allowDesktopClient && command.client === 'desktop'; controllerArchive = allowControllerArchive && command.client === 'archive-controller'; clearTimeout(timeout); peers.add(peer);
46
+ if (command.desktopProtocol === 1 && desktopClient) send(peer, { type: 'desktop_owner_ready', version: 1 });
46
47
  // One-shot controls need their result, not multi-megabyte broadcasts
47
48
  // that can fill the write queue before the result is sent.
48
49
  if (command.subscribeStatus !== false) {
49
- subscribers.add(peer);
50
- if (latest) send(peer, latest);
50
+ (desktopClient ? desktopSubscribers : subscribers).add(peer);
51
+ const snapshot = desktopClient ? latestDesktop : latest;
52
+ if (snapshot) send(peer, snapshot);
51
53
  }
52
54
  continue;
53
55
  }
54
- if (!allowedActions.has(command.action) || typeof command.id !== 'string' || command.id.length > 128 || command.hostId && command.hostId !== 'local' && !(controllerArchive && ['session_archive', 'session_restore', 'session_provider_setup'].includes(command.action))) {
56
+ if ((!desktopClient && !allowedActions.has(command.action)) || typeof command.id !== 'string' || command.id.length > 128 || command.hostId && command.hostId !== 'local' && !desktopClient && !(controllerArchive && ['session_archive', 'session_restore', 'session_provider_setup'].includes(command.action))) {
55
57
  send(peer, { type: 'result', id: command.id, ok: false, code: 'unsupported_remote_action' }); continue;
56
58
  }
57
59
  if (requests.size >= 256) { peer.destroy(); return; }
@@ -73,15 +75,23 @@ export async function createOwnerTransport({ root, nonce, allowedActions, dispat
73
75
  if (item) { requests.delete(frame.id); send(item.peer, { ...frame, id: item.id }); }
74
76
  } else if (frame.type === 'status' || frame.type === 'notify' && (!frame.hostId || frame.hostId === 'local')) {
75
77
  for (const peer of subscribers) send(peer, frame);
78
+ if (frame.type === 'notify') for (const peer of desktopSubscribers) send(peer, frame);
76
79
  }
77
80
  },
81
+ // Local native viewers need the same complete, scoped projection as the
82
+ // original window. Remote observers retain their local-only catalog.
83
+ publishDesktop(frame) {
84
+ if (frame.type !== 'status') return;
85
+ latestDesktop = frame;
86
+ for (const peer of desktopSubscribers) send(peer, frame);
87
+ },
78
88
  async close() { for (const peer of peers) peer.destroy(); await new Promise(resolve => server.close(resolve)); },
79
89
  };
80
90
  }
81
91
 
82
92
  // Returns false only when no compatible owner endpoint exists. Never starts a
83
93
  // second owner, kills a process, or converts a disconnect into an execution retry.
84
- export async function relayToOwner({ root, webOrigin, input = process.stdin, output = process.stdout }) {
94
+ export async function relayToOwner({ root, webOrigin, input = process.stdin, output = process.stdout, desktopClient = false }) {
85
95
  let owner;
86
96
  try { owner = JSON.parse(await readFile(join(root, 'desktop-app.lock', 'owner.json'), 'utf8')); }
87
97
  catch (error) { if (error.code === 'ENOENT') return false; throw error; }
@@ -97,7 +107,11 @@ export async function relayToOwner({ root, webOrigin, input = process.stdin, out
97
107
  if (webOrigin && (!owner.webOrigin || normalizeWebOrigin(owner.webOrigin) !== normalizeWebOrigin(webOrigin)))
98
108
  throw Object.assign(new Error('The existing SIMY owner belongs to another Web address. Keep its tasks and reconnect using its saved environment.'), { code: 'remote_owner_origin_mismatch' });
99
109
  } catch (error) { peer.destroy(); throw error; }
100
- peer.write(JSON.stringify({ nonce: owner.nonce }) + '\n');
110
+ peer.write(JSON.stringify({ nonce: owner.nonce, ...(desktopClient ? { client: 'desktop', desktopProtocol: 1 } : {}) }) + '\n');
111
+ // Old owners ignore unknown hello fields and expose only the restricted SSH
112
+ // projection. Never attach a native window or forward its controls as though
113
+ // that were a complete desktop endpoint. Keep the original owner untouched.
114
+ if (desktopClient) await confirmDesktopOwner(peer);
101
115
  peer.pipe(output, { end: false });
102
116
  const lines = createInterface({ input, crlfDelay: Infinity });
103
117
  lines.on('line', line => { if (line.length <= 16384 && !peer.destroyed) peer.write(line + '\n'); });
@@ -107,6 +121,34 @@ export async function relayToOwner({ root, webOrigin, input = process.stdin, out
107
121
  return true;
108
122
  }
109
123
 
124
+ function confirmDesktopOwner(peer) {
125
+ return new Promise((resolve, reject) => {
126
+ const incompatible = () => Object.assign(new Error('The running SIMY owner does not support desktop window handoff.'), { code: 'desktop_owner_update_required' });
127
+ let buffer = Buffer.alloc(0);
128
+ const finish = error => {
129
+ clearTimeout(timer);
130
+ peer.removeListener('data', data); peer.removeListener('close', closed); peer.removeListener('error', failed);
131
+ if (error) { peer.destroy(); reject(error); } else resolve();
132
+ };
133
+ const closed = () => finish(incompatible());
134
+ const failed = error => finish(error);
135
+ const data = chunk => {
136
+ const newline = chunk.indexOf(10);
137
+ const line = newline < 0 ? chunk : chunk.subarray(0, newline);
138
+ if (buffer.length + line.length > 16384) { finish(incompatible()); return; }
139
+ buffer = Buffer.concat([buffer, line]);
140
+ if (newline < 0) return;
141
+ let frame; try { frame = JSON.parse(buffer.toString('utf8')); } catch { finish(incompatible()); return; }
142
+ if (frame?.type !== 'desktop_owner_ready' || frame.version !== 1) { finish(incompatible()); return; }
143
+ peer.pause();
144
+ if (newline + 1 < chunk.length) peer.unshift(chunk.subarray(newline + 1));
145
+ finish();
146
+ };
147
+ const timer = setTimeout(closed, 5000);
148
+ peer.on('data', data); peer.once('close', closed); peer.once('error', failed);
149
+ });
150
+ }
151
+
110
152
  // A terminal command attaches to the existing execution owner. It never starts
111
153
  // another supervisor or retries a mutation after a lost response.
112
154
  export async function requestOwnerArchive({ root, command, signal, timeoutMs = 55_000 }) {
@@ -6,7 +6,7 @@ const messages = {
6
6
  claude_desktop_accessibility_required: 'Enable macOS Accessibility for the app running SIMY on the conversation host. Use simy sessions provider-setup --provider claude --host ALIAS --session UUID to open that host’s setting.',
7
7
  claude_desktop_ui_unavailable: 'Open the exact Claude Desktop Code conversation on the selected host and retry.',
8
8
  claude_desktop_ui_changed: 'The Claude Desktop window changed or is covered. Retry when the selected conversation is visible.',
9
- claude_desktop_running: 'Stop this conversation and turn off automatic continuation before organizing it.',
9
+ claude_desktop_running: 'Stop this conversation and turn off Delivery Loop before organizing it.',
10
10
  claude_desktop_unsaved_input: 'Claude Desktop contains unsent input. Preserve it on the conversation host before retrying.',
11
11
  claude_desktop_identity_unverified: 'Claude Desktop conversation identity could not be verified. Refresh the exact conversation.',
12
12
  claude_desktop_identity_changed: 'Claude Desktop account or conversation changed. Refresh before retrying.',
@@ -24,7 +24,7 @@ const messages = {
24
24
  session_model_unavailable: 'Select a model in the original remote conversation, then refresh.',
25
25
  model_metadata_unavailable: 'Select a model in the original remote conversation, then refresh.',
26
26
  session_workspace_changed: 'The remote workspace changed. Refresh and select the conversation again.',
27
- session_archived: 'Restore the original remote conversation before enabling Auto-continue.',
27
+ session_archived: 'Restore the original remote conversation before turning on Delivery Loop.',
28
28
  existing_loop: 'Use this remote conversation’s existing task controls.',
29
29
  task_stopped: 'This remote task was stopped. Review the original conversation and explicitly request resumption.',
30
30
  task_settled: 'The remote task has finished. Refresh and select its explicit follow-up controls.',
@@ -194,10 +194,18 @@ export function createRemoteHostManager({ root, store, spawnImpl = spawn, onChan
194
194
  if (status.controllerEventsVersion === 1) void pullEvents(connection).catch(() => {});
195
195
  else for (const event of status.controllerEvents || []) void receiveNotification(connection, event)?.catch(() => {});
196
196
  const expectedScope = preferenceScope;
197
- void preferences.change(expectedScope, state => {
197
+ connection.statusPersistence = preferences.change(expectedScope, state => {
198
198
  for (const row of status.sessionSupervision.sessions) {
199
199
  const key = JSON.stringify([connection.id, row.provider, row.session]);
200
200
  const saved = state.conversations[key] ||= {};
201
+ // A lost command response is not proof of a failed save. Only
202
+ // this newly received, authorized host frame may resolve it;
203
+ // cached/offline projections and a requested value cannot.
204
+ const auto = saved.policy?.session_set_auto_continue;
205
+ if (['unknown', 'confirming'].includes(auto?.state) &&
206
+ typeof row.autoContinue === 'boolean' && row.autoContinue === auto.command.enabled) {
207
+ auto.state = 'applied'; delete auto.code;
208
+ }
201
209
  if (['ready','partial'].includes(row.statistics?.state)) saved.statistics = { ...row.statistics, observedAt: row.statistics.observedAt || now() };
202
210
  if (!saved.importedRemoteDisplay) {
203
211
  saved.pinned ??= row.listPinned === true;
@@ -209,7 +217,7 @@ export function createRemoteHostManager({ root, store, spawnImpl = spawn, onChan
209
217
  state.hosts ||= {};
210
218
  state.hosts[connection.id] = { sessionSupervision: status.sessionSupervision, deliveryHooks: status.deliveryHooks,
211
219
  controllerCompletions: [...new Map([...(state.hosts[connection.id]?.controllerCompletions || []), ...(status.controllerCompletions || [])].map(item => [JSON.stringify([item.provider,item.provider_session_id,item.task_id]),item])).values()], tasks: status.tasks, sqmTasks: status.sqmTasks };
212
- }).catch(() => {});
220
+ }).then(changed).catch(() => {});
213
221
  }
214
222
  changed();
215
223
  } else if (frame.type === 'notify' && connection.ready && local && typeof frame.id === 'string' && ['finished','failed','paused','needs_decision'].includes(frame.kind)) {
@@ -508,6 +516,7 @@ export function createRemoteHostManager({ root, store, spawnImpl = spawn, onChan
508
516
  connection.flushing = true;
509
517
  const expectedScope = preferenceScope;
510
518
  try {
519
+ await connection.statusPersistence;
511
520
  const state = await preferences.load(expectedScope);
512
521
  const pending = Object.entries(state.conversations).flatMap(([key, row]) => {
513
522
  if (JSON.parse(key)[0] !== connection.id) return [];
@@ -536,7 +545,9 @@ export function createRemoteHostManager({ root, store, spawnImpl = spawn, onChan
536
545
  await preferences.change(expectedScope, data => {
537
546
  const current = data.conversations[key]?.policy?.[item.command.action];
538
547
  if (current?.command.id === item.command.id) {
539
- current.state = result.nextState || (result.ok ? 'applied' : result.code === 'remote_operation_unknown' ? 'unknown' : 'failed');
548
+ current.state = result.nextState || (result.ok
549
+ ? item.command.action === 'session_set_auto_continue' ? 'confirming' : 'applied'
550
+ : result.code === 'remote_operation_unknown' ? 'unknown' : 'failed');
540
551
  current.code = result.code;
541
552
  }
542
553
  });
@@ -99,14 +99,14 @@ export function sessionWorkState(candidate, run, { now = Date.now(), provider }
99
99
  // Saved consent is not execution: name the real dispatch blocker rather than
100
100
  // presenting a fenced conversation as waiting for its AI.
101
101
  if (candidate.autoContinue === true && candidate.dispatchReadiness?.state === 'blocked' && !['completed', 'stopped'].includes(run?.state))
102
- return result('blocked', '自動継続を送信できません', candidate.autoContinueReason || '自動継続はオンですが、作業を送信できません。',
102
+ return result('blocked', 'Delivery Loop の作業を送信できません', candidate.autoContinueReason || 'Delivery Loop はオンですが、作業を送信できません。',
103
103
  candidate.dispatchReadiness.code === 'update_recovery_required' ? 'update_reconnect' : candidate.dispatchReadiness.code === 'writer_reconnect_required' ? 'reconnect_original' : 'open_original', true);
104
104
  if (run?.state === 'verification_pending' &&
105
105
  (onlyManualAcceptancePending(run) || candidate.autoContinueStatus === 'needs_user'))
106
106
  return result('unfinished', '残りの作業あり', '実際の結果の確認が残っています。', 'open_original', true);
107
107
  if (candidate.autoContinue === true && ['starting', 'recovering'].includes(candidate.autoContinueStatus))
108
- return result(candidate.autoContinueStatus, candidate.autoContinueStatus === 'starting' ? '自動継続を準備中' : '自動復旧中',
109
- candidate.autoContinueReason || '自動継続はオンで保存されています。');
108
+ return result(candidate.autoContinueStatus, candidate.autoContinueStatus === 'starting' ? 'Delivery Loop を準備中' : '自動復旧中',
109
+ candidate.autoContinueReason || 'Delivery Loop はオンで保存されています。');
110
110
  if ((run?.autoRecovery || run?.managed?.autoRecovery) && ['recovery_required', 'auto_resuming'].includes(run.state)) return result('checking', 'AIが状況確認中', '自動復旧の結果を待っています。');
111
111
  if (candidate.autoContinue === true && candidate.autoContinueStatus === 'supervising' && run?.state === 'verification_pending')
112
112
  return result('checking', '結果を確認中', '作業の結果を自動で確認しています。');
@@ -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.4.0";
11
+ export const SQM_AGENT_SKILL_VERSION = "3.5.0";
12
12
  export const SIMY_CODEX_PLUGIN_ID = "simy";
13
13
  export const SIMY_CODEX_PLUGIN_VERSION = "0.1.0";
14
14
 
@@ -22,10 +22,10 @@ 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 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.
25
+ description: Apply a SIMY SQM check as a gate before creating or proposing a pull request, only after the user opted in with @simy, this SQM skill, or a request to use SQM for the task. An immediate SQM check of the current work uses the plugin sqm-loop entry; Delivery Loop uses the separate continue entry. Ordinary PR creation or git push alone does not activate this skill.
26
26
  ---
27
27
 
28
- # SIMY SQM PR check
28
+ # SIMY SQM check before a PR
29
29
 
30
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
 
@@ -97,7 +97,7 @@ export async function claudePluginFiles() {
97
97
  for (const [name, contents] of [...files].sort(([a], [b]) => a.localeCompare(b))) hash.update(name).update("\0").update(contents);
98
98
  const manifest = {
99
99
  name: "simy", version: `${sourceManifest.version.split("+")[0]}+claude.${hash.digest("hex").slice(0, 20)}`,
100
- description: "SIMY Auto Continue through the original conversation controller, quality checks, and PR review for Claude Code.",
100
+ description: "SIMY Delivery Loop through the original conversation controller, SQM checks, and PR review for Claude Code.",
101
101
  author: sourceManifest.author, repository: sourceManifest.repository,
102
102
  license: sourceManifest.license, keywords: sourceManifest.keywords,
103
103
  };