@awak-app/simy-cli 0.7.17 → 0.7.18

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.7.17",
3
+ "version": "0.7.18",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,7 +13,7 @@ import {
13
13
  } from "./durable-local-task-steps-contract.js";
14
14
  import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
15
15
 
16
- export const CLI_VERSION = "0.7.17";
16
+ export const CLI_VERSION = "0.7.18";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function narrativeImageCapabilityMetadata() {
@@ -773,7 +773,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
773
773
  for (const session of status.sessionSupervision.sessions) {
774
774
  const key = teamThreadKey(agent.getTeamThreadsScope?.() || {}, session);
775
775
  const own = status.teamThreads?.own.find(item => item.threadKey === key);
776
- session.teamSharing = { enabled: own?.enabled === true, pendingStop: status.teamThreads?.pendingStops.includes(key) === true };
776
+ session.teamSharing = { threadKey: key, enabled: own?.enabled === true, pendingStop: status.teamThreads?.pendingStops.includes(key) === true, confirmationRequired: status.teamThreads?.confirmationRequired.includes(key) === true };
777
777
  }
778
778
  const latestAccess = await accessSnapshot();
779
779
  if (JSON.stringify(await readProviderLinks(root)) !== requestedLinks ||
@@ -1138,9 +1138,20 @@ export async function startDesktopHost({ input = process.stdin, output = process
1138
1138
  else if(action === 'team_thread_open') ok = await teamThreads.open({memberId:command.memberId,threadKey:command.threadKey});
1139
1139
  else {
1140
1140
  if (command.organizationId !== undefined && command.organizationId !== access.organizationId) throw new Error('Sharing organization changed. Confirm again.');
1141
- const selected = sessionStatus.sessions.find(item => item.provider === command.provider && item.session === command.session);
1142
- if(!selected) throw new Error('Refresh and select your conversation again.');
1143
- ok = await teamThreads.setSharing(selected,command.enabled);
1141
+ // Publication reads this host's provider context. Never reinterpret
1142
+ // a remote or ambiguous identity as a same-ID local conversation.
1143
+ if ((command.hostId && command.hostId !== 'local') || (!command.hostId && remoteHosts?.hasSession(command.session))) {
1144
+ throw new Error('Team sharing requires an explicitly selected local conversation.');
1145
+ }
1146
+ const selected = sessionStatus.sessions.find(item => (!item.hostId || item.hostId === 'local') && item.provider === command.provider && item.session === command.session);
1147
+ if (command.enabled === false) {
1148
+ // Revocation uses authenticated own rows, never provider context.
1149
+ const key = command.threadKey ?? teamThreadKey(agent.getTeamThreadsScope(), { provider: command.provider, session: command.session });
1150
+ ok = await teamThreads.stopSharing(key);
1151
+ } else {
1152
+ if(!selected || command.threadKey) throw new Error('Refresh and select your conversation again.');
1153
+ ok = await teamThreads.setSharing(selected,command.enabled);
1154
+ }
1144
1155
  }
1145
1156
  emit({type:'result',id:command.id,ok:ok===true}); void publish().catch(() => {}); return;
1146
1157
  }
@@ -1457,6 +1468,22 @@ export async function startDesktopHost({ input = process.stdin, output = process
1457
1468
  await authorizeWork(commandScope);
1458
1469
  const result = await deadlines.decide(context, { ...command, requestId: command.requestId || command.decisionId });
1459
1470
  if (result.status === 'conflict') throw Object.assign(new Error('Deadline changed; refresh before deciding.'), { code: 'conflict' });
1471
+ } else if (action === "session_set_pinned") {
1472
+ if (typeof command.enabled !== 'boolean') throw new Error('Choose whether to pin this conversation.');
1473
+ const candidate = await selectedSession(command, { preferObserved: true });
1474
+ await authorizeWork(commandScope);
1475
+ const pinScope = command.pinScope ?? 'legacy';
1476
+ if (typeof pinScope !== 'string' || !pinScope || pinScope.length > 512) throw new Error('Invalid pin scope.');
1477
+ await updateRecord(deliveryRoot, command.provider, command.session, previous => {
1478
+ const scopes = new Set(Array.isArray(previous?.listPinScopes) ? previous.listPinScopes : previous?.listPinned ? ['legacy'] : []);
1479
+ if (command.enabled) scopes.add(pinScope); else scopes.delete(pinScope);
1480
+ return {
1481
+ ...(previous || { schemaVersion: 1, provider: command.provider, session: command.session, cwd: candidate.cwd, observedAt: Date.now() }),
1482
+ listPinned: scopes.size > 0, listPinScopes: [...scopes],
1483
+ ...(command.enabled ? { listMetadata: { title: candidate.title || '', createdAt: candidate.createdAt ?? null, lastActivityAt: candidate.lastActivityAt ?? null } } : {}),
1484
+ };
1485
+ });
1486
+ refreshSessions(true);
1460
1487
  } else if (action === "session_archive" || action === "session_restore") {
1461
1488
  const candidate = await selectedSession(command, { preferObserved: true });
1462
1489
  await authorizeWork(commandScope);
@@ -25,7 +25,7 @@ function rememberMetadata(path, value) {
25
25
  function candidateMetadata(id, metadata, lastActivityAt, observedAt) {
26
26
  return {
27
27
  provider: 'claude', session: id, ...(metadata.parentSessionId ? { parentSession: { provider: 'claude', session: metadata.parentSessionId } } : {}), cwd: metadata.cwd, title: metadata.title,
28
- source: 'claude_cli', lastActivityAt,
28
+ source: 'claude_cli', lastActivityAt, createdAt: metadata.createdAt ?? null,
29
29
  observation: { source: 'claude_local_index', observedAt },
30
30
  availability: 'unsupported', model: null,
31
31
  capabilities: { canMonitor: true, canEnroll: false, canAutoContinue: false },
@@ -62,7 +62,7 @@ function metadataFromPrefix(bytes, id) {
62
62
  const text = bytes.toString('utf8');
63
63
  const complete = text.slice(0, text.lastIndexOf('\n') + 1);
64
64
  if (!complete) return null;
65
- let cwd = '', title = '', customTitle = '', parentSessionId = null, found = false;
65
+ let cwd = '', title = '', customTitle = '', parentSessionId = null, found = false, createdAt = null;
66
66
  for (const line of complete.split('\n').slice(0, MAX_LINES)) {
67
67
  if (!line) continue;
68
68
  let item;
@@ -73,6 +73,8 @@ function metadataFromPrefix(bytes, id) {
73
73
  if (item.type === 'custom-title' && item.isSidechain !== true) { customTitle = sessionTitle(item.customTitle); continue; }
74
74
  if (item.type !== 'user' && item.type !== 'assistant') continue;
75
75
  if (item.isSidechain === true) return null;
76
+ const timestamp = typeof item.timestamp === 'string' ? Date.parse(item.timestamp) : item.timestamp;
77
+ if (Number.isFinite(timestamp) && timestamp > 0) createdAt = createdAt === null ? timestamp : Math.min(createdAt, timestamp);
76
78
  if (typeof item.cwd !== 'string' || !isAbsolute(item.cwd)) continue;
77
79
  if (!cwd) cwd = resolve(item.cwd);
78
80
  // A conversation can cd into a child directory without changing identity.
@@ -80,7 +82,7 @@ function metadataFromPrefix(bytes, id) {
80
82
  if (!title) title = sessionTitle(item.slug);
81
83
  found = true;
82
84
  }
83
- return found ? { cwd, title: customTitle || title, parentSessionId } : null;
85
+ return found ? { cwd, title: customTitle || title, parentSessionId, createdAt } : null;
84
86
  }
85
87
 
86
88
  // /rename appends a custom-title envelope, often beyond the bounded prefix.
@@ -222,6 +222,7 @@ export function codexThreadMetadata(thread) {
222
222
  title: typeof thread.name === 'string' && thread.name.trim() ? thread.name.slice(0, 200) : '',
223
223
  source: typeof thread.source === 'string' ? thread.source : thread.source?.type || 'unknown',
224
224
  lastActivityAt: Number.isFinite(thread.updatedAt) ? thread.updatedAt * 1000 : null,
225
+ createdAt: Number.isFinite(thread.createdAt) && thread.createdAt > 0 ? thread.createdAt * 1000 : null,
225
226
  model: typeof thread.model === 'string' ? thread.model : null,
226
227
  // Preserve native wait flags for presentation without changing isRunning,
227
228
  // which also protects the provider writer lock. Unknown flags fail closed.
@@ -4,7 +4,7 @@ export const RECENT_SESSION_LIMIT = 200;
4
4
 
5
5
  // Explicit tracking and unsettled work remain visible outside recent history.
6
6
  // Completed, untracked records are history, not an unlimited extra catalog.
7
- export const retainedSession = record => Boolean(record?.autoContinue || record?.monitor || record?.tracked ||
7
+ export const retainedSession = record => Boolean(record?.listPinned || record?.autoContinue || record?.monitor || record?.tracked ||
8
8
  record?.run && !TERMINAL.has(record.run.state));
9
9
 
10
10
  export function recentSessionWindow(items, { limit = RECENT_SESSION_LIMIT, priority = () => false } = {}) {
@@ -231,8 +231,8 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
231
231
  // A hook observation can belong to an AI-created worker. Only explicit
232
232
  // supervision may restore a conversation absent from provider discovery.
233
233
  if (!byKey.has(key) && (retainedSession(record) || includeArchived && record.listHidden === true || requiredAncestors.has(key) && (record.observedAt || record.admission?.validatedAt))) byKey.set(key, {
234
- provider: record.provider, session: record.session, cwd: record.cwd, title: '', archived, source: record.admission?.source || 'hook',
235
- lastActivityAt: record.observedAt || record.admission?.validatedAt || record.run?.updatedAt || null,
234
+ provider: record.provider, session: record.session, cwd: record.cwd, title: record.listMetadata?.title || '', createdAt: record.listMetadata?.createdAt ?? null, archived, source: record.admission?.source || 'hook',
235
+ lastActivityAt: record.listMetadata?.lastActivityAt ?? record.observedAt ?? record.admission?.validatedAt ?? record.run?.updatedAt ?? null,
236
236
  observation: { source: record.admission?.source || `${record.provider}_hook`, observedAt: record.observedAt || record.admission?.validatedAt || null },
237
237
  availability: 'unknown', model: record.admission?.model || null,
238
238
  });
@@ -327,7 +327,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
327
327
  autoContinueEpoch: record?.autoContinue === true ? record.autoContinueEpoch : null,
328
328
  autoContinueAvailability: availability,
329
329
  ...autoContinueState(record, isRunning, availability),
330
- lastActivityAt: item.lastActivityAt, observation, availability: item.availability,
330
+ lastActivityAt: item.lastActivityAt, createdAt: item.createdAt ?? null, observation, availability: item.availability,
331
331
  taskId: run?.managed?.taskId || record?.associationTaskId || null,
332
332
  binding: { mode, taskId: run?.managed?.taskId || record?.associationTaskId || null },
333
333
  originalSessionTarget: originalSessionTarget(item.provider, item.session),
@@ -13,7 +13,7 @@ export const remoteHostId = alias => `remote-ssh-discovered:${alias}`;
13
13
  const shellQuote = value => "'" + value.replaceAll("'", "'\\''") + "'";
14
14
  const bootstrap = 'import { realpathSync } from "node:fs"; import { dirname, join } from "node:path"; import { pathToFileURL } from "node:url"; const entry = join(dirname(realpathSync(process.argv[1])), "session-supervision", "remote-entry.js"); await import(pathToFileURL(entry).href);';
15
15
  export const REMOTE_HOST_COMMAND = `exec sh -lc ${shellQuote(`exec env SIMY_REMOTE_CHILD=1 SIMY_AUTO_UPDATE=0 node --input-type=module -e ${shellQuote(bootstrap)} "$(command -v simy)"`)}`;
16
- const allowedActions = new Set(['refresh', 'session_statistics', 'session_archive', 'session_restore', 'session_track', 'session_untrack',
16
+ const allowedActions = new Set(['refresh', 'session_set_pinned', 'session_statistics', 'session_archive', 'session_restore', 'session_track', 'session_untrack',
17
17
  'session_monitor', 'session_unmonitor', 'session_enroll', 'session_set_auto_continue', 'session_analyze', 'session_continue', 'session_answer',
18
18
  'session_deadline_decide', 'session_action_refresh', 'session_action_select', 'session_action_unlink',
19
19
  'delivery_pause', 'delivery_stop', 'delivery_reject', 'delivery_resume', 'delivery_replan', 'delivery_approve',
@@ -113,6 +113,7 @@ export function createTeamThreads({
113
113
  selected = null,
114
114
  memberFilter = null,
115
115
  pending = new Map(),
116
+ publicationGrants = new Map(),
116
117
  storageFailed = false,
117
118
  lastAllowed = false;
118
119
  function scope() {
@@ -122,11 +123,12 @@ export function createTeamThreads({
122
123
  generation++;
123
124
  const sameIdentity = scopeKey === current.key;
124
125
  scopeKey = current.key;
125
- const own = sameIdentity ? value.own : [];
126
+ const own = sameIdentity && current.authenticated ? value.own : [];
126
127
  value = { ...empty(), own };
127
128
  selected = null;
128
129
  memberFilter = null;
129
130
  if (!sameIdentity) pending = new Map();
131
+ publicationGrants.clear();
130
132
  lastPoll = -Infinity;
131
133
  }
132
134
  lastAllowed = current.allowed;
@@ -135,7 +137,7 @@ export function createTeamThreads({
135
137
  }
136
138
  const snapshot = () => {
137
139
  scope();
138
- return structuredClone({ ...value, pendingStops: [...pending.keys()] });
140
+ return structuredClone({ ...value, pendingStops: [...pending.keys()], confirmationRequired: value.own.filter(row => row.enabled && publicationGrants.get(row.threadKey) !== row.version).map(row => row.threadKey) });
139
141
  };
140
142
  const fenceKey = (current, key) => digest(`${current.fenceKey}:${key}`);
141
143
  async function run(fn, allowOff = false) {
@@ -272,6 +274,11 @@ export function createTeamThreads({
272
274
  incomplete: result.incomplete,
273
275
  };
274
276
  }
277
+ function sourceObservedAt(candidate) {
278
+ const observed = candidate.observation?.observedAt;
279
+ if (!Number.isFinite(observed) || observed <= 0 || observed > now()) invalid();
280
+ return new Date(observed).toISOString();
281
+ }
275
282
  async function payload(candidate, signal) {
276
283
  const result = await readContext(candidate, { signal });
277
284
  if (!Array.isArray(result?.messages) || !result.messages.length) invalid();
@@ -295,7 +302,7 @@ export function createTeamThreads({
295
302
  240,
296
303
  ),
297
304
  state: running ? "running" : "unknown",
298
- observedAt: new Date(now()).toISOString(),
305
+ observedAt: sourceObservedAt(candidate),
299
306
  messages,
300
307
  truncated: result.truncated === true,
301
308
  incomplete: result.incomplete === true,
@@ -322,13 +329,18 @@ export function createTeamThreads({
322
329
  const fence = await fenceStore.read(fenceKey(current, item.threadKey));
323
330
  if (!valid()) return;
324
331
  if (fence) {
325
- pending.set(item.threadKey, fence.expectedVersion);
326
- await stop(send, current, item.threadKey, fence.expectedVersion);
332
+ // A lost OFF response (or intervening version) must reconcile against
333
+ // authenticated current state, rather than retry a stale CAS forever.
334
+ pending.set(item.threadKey, item.version);
335
+ if (item.enabled) await stop(send, current, item.threadKey, item.version);
336
+ else await clearStop(current, item.threadKey);
327
337
  }
328
338
  }
329
339
  if (publish && !storageFailed)
330
340
  for (const item of [...value.own]) {
331
- if (!item.enabled || pending.has(item.threadKey)) continue;
341
+ // Server consent alone cannot re-arm publication after a process restart:
342
+ // an OFF may have failed both durable storage and the network.
343
+ if (!item.enabled || pending.has(item.threadKey) || publicationGrants.get(item.threadKey) !== item.version) continue;
332
344
  const candidate = candidates.find(
333
345
  (c) => teamThreadKey(current, c) === item.threadKey,
334
346
  );
@@ -384,9 +396,23 @@ export function createTeamThreads({
384
396
  if (valid()) onChange();
385
397
  });
386
398
  },
387
- async setSharing(candidate, enabled) {
399
+ async stopSharing(threadKey) {
400
+ scope();
401
+ if (!keyPattern.test(threadKey) || !value.own.some(row => row.threadKey === threadKey)) return false;
402
+ return setSharing(null, false, threadKey);
403
+ },
404
+ setSharing,
405
+ close() {
406
+ closed = true;
407
+ controller?.abort();
408
+ generation++;
409
+ publicationGrants.clear();
410
+ value = empty();
411
+ },
412
+ };
413
+ async function setSharing(candidate, enabled, existingKey) {
388
414
  const current = scope(),
389
- key = teamThreadKey(current, candidate),
415
+ key = existingKey ?? teamThreadKey(current, candidate),
390
416
  item = value.own.find((row) => row.threadKey === key),
391
417
  version = item?.version ?? 0;
392
418
  if (
@@ -408,6 +434,7 @@ export function createTeamThreads({
408
434
  storageFailed = false;
409
435
  }
410
436
  if (!enabled) {
437
+ publicationGrants.delete(key);
411
438
  pending.set(key, version);
412
439
  controller?.abort();
413
440
  generation++;
@@ -442,16 +469,10 @@ export function createTeamThreads({
442
469
  receipt.version !== version + 1
443
470
  )
444
471
  invalid();
472
+ if (valid() && !pending.has(key)) publicationGrants.set(key, receipt.version);
445
473
  }
446
474
  if (current.allowed) await context(send);
447
475
  if (valid()) onChange();
448
476
  }, !enabled);
449
- },
450
- close() {
451
- closed = true;
452
- controller?.abort();
453
- generation++;
454
- value = empty();
455
- },
456
- };
477
+ }
457
478
  }