@margay/ccl-core 1.4.6-beta.0 → 1.5.0-alpha.0

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.
@@ -13,4 +13,4 @@ export { defaultStoreDir, fileSessionStore } from './store.js';
13
13
  export { spawnCliTransport } from './transport.js';
14
14
  export type { RecoveryDiagnostic } from './errorDiagnostics.js';
15
15
  export type { GatewayRequestPhase, GatewayRequestProgress } from './requestProgress.js';
16
- export type { AccumulatedUsage, CanUseToolContext, CanUseToolFn, CanUseToolResult, InitializationResult, LifecycleHooks, PendingApproval, PendingDecision, ProviderConfig, Query, QueryOptions, SDKEvent, SDKUserMessage, SessionStore, Transport, Usage, WireMessage, } from './types.js';
16
+ export type { AccumulatedUsage, CanUseToolContext, CanUseToolFn, CanUseToolResult, InitializationResult, LifecycleHooks, PendingApproval, PendingDecision, ProviderConfig, Query, QueryOptions, SDKEvent, SDKUserMessage, SessionStore, SessionLifecycleEvent, Transport, Usage, WireMessage, } from './types.js';
package/dist/sdk/query.js CHANGED
@@ -35,7 +35,7 @@ import { fileURLToPath } from 'url';
35
35
  import { SdkMcpRegistry } from './mcpServer.js';
36
36
  import { fileSessionStore } from './store.js';
37
37
  import { spawnCliTransport } from './transport.js';
38
- import { isAsyncInput, readWithInput } from './queryInput.js';
38
+ import { isAsyncInput, normalizeUserInput, readWithInput } from './queryInput.js';
39
39
  const DEFAULT_CAN_USE_TOOL_TIMEOUT_MS = 300_000;
40
40
  /** Builds the CLI argv for a query. Exported for unit tests. */
41
41
  export function buildCliArgs(options) {
@@ -253,6 +253,7 @@ export async function* queryWithTransport(prompt, options, transport) {
253
253
  const hooks = options.hooks ?? {};
254
254
  const ownRequestIds = new Set();
255
255
  const toolNamesById = new Map();
256
+ const toolOrigins = new Map();
256
257
  let lastSessionId = options.sessionId ?? options.resume;
257
258
  let turn = 0;
258
259
  let sawResult = false;
@@ -321,11 +322,11 @@ export async function* queryWithTransport(prompt, options, transport) {
321
322
  const userContent = options.promptContentBlocks && options.promptContentBlocks.length > 0
322
323
  ? options.promptContentBlocks
323
324
  : [{ type: 'text', text: prompt }];
324
- await transport.write({
325
+ await transport.write(normalizeUserInput({
325
326
  type: 'user',
326
327
  message: { role: 'user', content: userContent },
327
328
  parent_tool_use_id: null,
328
- });
329
+ }));
329
330
  // slice sdk-observability-hooks-usage (G5): the host's prompt was submitted to the agent.
330
331
  await runHook(hooks.onUserPromptSubmit &&
331
332
  (() => hooks.onUserPromptSubmit({ prompt, sessionId: lastSessionId })));
@@ -341,6 +342,10 @@ export async function* queryWithTransport(prompt, options, transport) {
341
342
  })
342
343
  : transport.read();
343
344
  for await (const rawMessage of messages) {
345
+ let attribution = rawMessage.input_uuid === null
346
+ ? { origin: 'background' } : typeof rawMessage.input_uuid === 'string'
347
+ ? { origin: 'user', turnId: rawMessage.input_uuid }
348
+ : streamingInput && inputState.turnId ? { turnId: inputState.turnId } : {};
344
349
  // Keep ordinary raw passthrough unchanged. Diagnostic payloads are an
345
350
  // untrusted error boundary even when a host asks for raw wire events.
346
351
  if (rawMessage.type === 'system' && rawMessage.subtype === 'request_progress' && !options.includeRequestProgress)
@@ -364,18 +369,18 @@ export async function* queryWithTransport(prompt, options, transport) {
364
369
  }
365
370
  }
366
371
  if (options.includeRaw)
367
- yield { type: 'raw', message };
372
+ yield { ...attribution, type: 'raw', message };
368
373
  switch (message.type) {
369
374
  case 'system': {
370
375
  if (message.subtype === 'request_progress' && progress)
371
- yield {
376
+ yield { ...attribution,
372
377
  type: 'request_progress', progress,
373
378
  sessionId: typeof message.session_id === 'string' ? message.session_id : undefined,
374
379
  };
375
380
  if (message.subtype === 'error_diagnostic') {
376
381
  const diagnostic = sanitizeRecoveryDiagnostic(message.diagnostic);
377
382
  if (diagnostic)
378
- yield {
383
+ yield { ...attribution,
379
384
  type: 'error_diagnostic', diagnostic,
380
385
  sessionId: typeof message.session_id === 'string' ? message.session_id : undefined,
381
386
  };
@@ -386,19 +391,19 @@ export async function* queryWithTransport(prompt, options, transport) {
386
391
  const sessionId = message.session_id;
387
392
  for (const block of contentBlocks(message.message)) {
388
393
  if (block.type === 'text' && typeof block.text === 'string') {
389
- yield { type: 'assistant_text', text: block.text, sessionId };
394
+ yield { ...attribution, type: 'assistant_text', text: block.text, sessionId };
390
395
  }
391
396
  else if (block.type === 'tool_use') {
392
397
  const toolUseId = String(block.id ?? '');
393
398
  const toolName = String(block.name ?? '');
394
399
  toolNamesById.set(toolUseId, toolName);
395
- yield {
400
+ toolOrigins.set(toolUseId, attribution);
401
+ yield { ...attribution,
396
402
  type: 'tool_use',
397
403
  toolName,
398
404
  input: (block.input ?? {}),
399
405
  toolUseId,
400
- sessionId,
401
- };
406
+ sessionId, };
402
407
  }
403
408
  }
404
409
  turn += 1;
@@ -420,7 +425,7 @@ export async function* queryWithTransport(prompt, options, transport) {
420
425
  : Array.isArray(block.content) ? block.content.map(part => part?.type === 'text' && typeof part.text === 'string' ? { ...part, text: sanitizeErrorText(part.text) } : part)
421
426
  : describeError(block.content)
422
427
  : block.content;
423
- yield {
428
+ yield { ...attribution,
424
429
  type: 'tool_result',
425
430
  toolUseId,
426
431
  content: resultContent,
@@ -435,6 +440,8 @@ export async function* queryWithTransport(prompt, options, transport) {
435
440
  isError,
436
441
  sessionId,
437
442
  })));
443
+ toolOrigins.delete(toolUseId);
444
+ toolNamesById.delete(toolUseId);
438
445
  }
439
446
  }
440
447
  break;
@@ -473,8 +480,9 @@ export async function* queryWithTransport(prompt, options, transport) {
473
480
  const toolName = String(request.tool_name ?? '');
474
481
  const input = (request.input ?? {});
475
482
  const toolUseId = String(request.tool_use_id ?? '');
483
+ attribution = toolOrigins.get(toolUseId) ?? attribution;
476
484
  const agentId = typeof request.agent_id === 'string' ? request.agent_id : undefined;
477
- yield {
485
+ yield { ...attribution,
478
486
  type: 'permission_request',
479
487
  requestId,
480
488
  toolName,
@@ -509,7 +517,7 @@ export async function* queryWithTransport(prompt, options, transport) {
509
517
  await store.deletePendingApproval(pendingApproval.sessionId);
510
518
  pendingDecision = undefined;
511
519
  pendingApproval = null;
512
- yield {
520
+ yield { ...attribution,
513
521
  type: 'permission_resolved',
514
522
  requestId,
515
523
  toolUseId,
@@ -546,13 +554,13 @@ export async function* queryWithTransport(prompt, options, transport) {
546
554
  },
547
555
  },
548
556
  });
549
- yield {
557
+ yield { ...attribution,
550
558
  type: 'error',
551
559
  message: 'pending approval requested before any session_id was observed',
552
560
  fatal: false,
553
561
  code: 'pending_without_session',
554
562
  };
555
- yield {
563
+ yield { ...attribution,
556
564
  type: 'permission_resolved',
557
565
  requestId,
558
566
  toolUseId,
@@ -574,7 +582,7 @@ export async function* queryWithTransport(prompt, options, transport) {
574
582
  // the model must not see a denial. Terminating here leaves the
575
583
  // transcript ending at the unresolved tool_use, which the
576
584
  // resume path drops and re-drives.
577
- yield {
585
+ yield { ...attribution,
578
586
  type: 'suspended',
579
587
  sessionId,
580
588
  requestId,
@@ -599,7 +607,7 @@ export async function* queryWithTransport(prompt, options, transport) {
599
607
  response: outcome.decision,
600
608
  },
601
609
  });
602
- yield {
610
+ yield { ...attribution,
603
611
  type: 'permission_resolved',
604
612
  requestId,
605
613
  toolUseId,
@@ -618,7 +626,7 @@ export async function* queryWithTransport(prompt, options, transport) {
618
626
  if (ownRequestIds.has(requestId)) {
619
627
  ownRequestIds.delete(requestId);
620
628
  if (response.subtype === 'error') {
621
- yield {
629
+ yield { ...attribution,
622
630
  type: 'error',
623
631
  message: `initialize failed: ${String(response.error)}`,
624
632
  fatal: false,
@@ -644,9 +652,9 @@ export async function* queryWithTransport(prompt, options, transport) {
644
652
  // Metering must not break the conversation stream.
645
653
  }
646
654
  }
647
- yield {
655
+ yield { ...attribution,
648
656
  type: 'result',
649
- ...(streamingInput ? { turnId: inputState.turnId } : {}),
657
+ ...(streamingInput ? { turnId: inputState.resultTurnId } : {}),
650
658
  subtype: String(message.subtype ?? 'unknown'),
651
659
  usage,
652
660
  totalCostUsd,
@@ -666,6 +674,12 @@ export async function* queryWithTransport(prompt, options, transport) {
666
674
  structuredOutput: message
667
675
  .structured_output,
668
676
  };
677
+ for (const [id, origin] of toolOrigins) {
678
+ if (origin.turnId === attribution.turnId && origin.origin === attribution.origin) {
679
+ toolOrigins.delete(id);
680
+ toolNamesById.delete(id);
681
+ }
682
+ }
669
683
  sessionEnded = !streamingInput;
670
684
  if (!streamingInput)
671
685
  await runHook(hooks.onSessionEnd &&
@@ -682,7 +696,7 @@ export async function* queryWithTransport(prompt, options, transport) {
682
696
  // (token-level delta). Only arrives when includePartialMessages passed the flag.
683
697
  const streamEvent = message.event;
684
698
  if (streamEvent && typeof streamEvent.type === 'string') {
685
- yield {
699
+ yield { ...attribution,
686
700
  type: 'stream_event',
687
701
  event: streamEvent,
688
702
  sessionId: message.session_id,
@@ -5,6 +5,7 @@ export declare function normalizeUserInput(value: unknown): SDKUserMessage;
5
5
  export type InputState = {
6
6
  finished: boolean;
7
7
  turnId?: string;
8
+ resultTurnId?: string;
8
9
  };
9
10
  /**
10
11
  * One input is in flight at a time. Keep reading controls while the producer
@@ -53,10 +53,22 @@ export async function* readWithInput(input, transport, state, submitted) {
53
53
  pendingWire = undefined;
54
54
  if (next.value.done)
55
55
  return;
56
+ const message = next.value.value;
57
+ const resultId = Object.prototype.hasOwnProperty.call(message, 'input_uuid')
58
+ ? typeof message.input_uuid === 'string' ? message.input_uuid : undefined
59
+ : state.turnId; // Compatibility with CLIs predating explicit attribution.
60
+ const completesInput = message.type === 'result' && state.turnId !== undefined && resultId === state.turnId;
61
+ if (message.type === 'result')
62
+ state.resultTurnId = resultId;
56
63
  yield next.value.value;
57
64
  if (next.value.value.type === 'result') {
58
- state.turnId = undefined;
59
- pendingInput = pull();
65
+ if (completesInput)
66
+ state.turnId = undefined;
67
+ // Scheduled work can finish while the host is already waiting for
68
+ // the next user input. Replacing that pull loses its eventual value
69
+ // and strands a Session turn behind another source.next().
70
+ if (!state.turnId)
71
+ pendingInput ??= pull();
60
72
  }
61
73
  }
62
74
  }
@@ -1,4 +1,4 @@
1
- import type { Query, QueryOptions, SDKEvent, SDKUserMessage, Transport } from './types.js';
1
+ import type { Query, QueryOptions, SDKEvent, SDKUserMessage, SessionLifecycleEvent, Transport } from './types.js';
2
2
  export type TurnResult = Extract<SDKEvent, {
3
3
  type: 'result';
4
4
  }>;
@@ -8,6 +8,9 @@ export type SessionOptions = QueryOptions & {
8
8
  maxPendingTurns?: number;
9
9
  /** Events are delivered as they arrive; Session does not retain an event history. */
10
10
  onEvent?: (event: SDKEvent) => void;
11
+ /** Deterministic queue/dispatch/permission/terminal observations. No model call.
12
+ * Keep observers fast; thrown errors are isolated from execution. */
13
+ onLifecycle?: (event: SessionLifecycleEvent) => void;
11
14
  };
12
15
  export interface Turn {
13
16
  readonly id: string;
@@ -29,6 +32,7 @@ export declare class Session {
29
32
  private closed;
30
33
  private drainWaiters;
31
34
  private readonly pumping;
35
+ private sessionId?;
32
36
  constructor(options?: SessionOptions, transport?: Transport);
33
37
  prompt(content: SDKUserMessage['message']['content']): Turn;
34
38
  followUp(content: SDKUserMessage['message']['content']): Turn;
@@ -37,6 +41,7 @@ export declare class Session {
37
41
  private enqueue;
38
42
  private inputs;
39
43
  private consume;
44
+ private observe;
40
45
  private fail;
41
46
  private notifyDrained;
42
47
  interrupt(): Promise<void>;
@@ -16,6 +16,7 @@ export class Session {
16
16
  closed = false;
17
17
  drainWaiters = [];
18
18
  pumping;
19
+ sessionId;
19
20
  constructor(options = {}, transport) {
20
21
  this.limit = options.maxPendingTurns ?? 32;
21
22
  if (!Number.isSafeInteger(this.limit) || this.limit < 1) {
@@ -24,9 +25,23 @@ export class Session {
24
25
  if (options.pendingDecision)
25
26
  throw new Error('ccl-sdk: resume a pending decision before creating a Session');
26
27
  this.options = options;
28
+ this.sessionId = options.sessionId ?? options.resume;
27
29
  const owned = transport ?? spawnForOptions(options);
28
30
  this.pid = owned.pid;
29
- this.handle = queryHandleWithTransport(this.inputs(), options, owned);
31
+ this.handle = queryHandleWithTransport(this.inputs(), { ...options, hooks: {
32
+ ...options.hooks,
33
+ onUserPromptSubmit: async (event) => {
34
+ if (this.active && !this.active.started) {
35
+ this.active.started = true;
36
+ this.observe({ type: 'turn_started', turnId: this.active.turn.id });
37
+ }
38
+ await options.hooks?.onUserPromptSubmit?.(event);
39
+ },
40
+ onSessionStart: async (event) => {
41
+ this.sessionId = event.sessionId;
42
+ await options.hooks?.onSessionStart?.(event);
43
+ },
44
+ } }, owned);
30
45
  this.controls = this.handle;
31
46
  this.pumping = this.consume();
32
47
  }
@@ -63,6 +78,7 @@ export class Session {
63
78
  this.queue.unshift(entry);
64
79
  else
65
80
  this.queue.push(entry);
81
+ this.observe({ type: 'turn_queued', turnId: id });
66
82
  this.wake?.();
67
83
  return turn;
68
84
  }
@@ -87,17 +103,31 @@ export class Session {
87
103
  async consume() {
88
104
  try {
89
105
  for await (const event of this.handle) {
90
- const correlated = { ...event, turnId: event.turnId ?? this.active?.turn.id };
106
+ if ('sessionId' in event && event.sessionId)
107
+ this.sessionId = event.sessionId;
108
+ const correlated = { ...event, turnId: event.turnId ?? (event.type === 'result' || event.origin === 'background' ? undefined : this.active?.turn.id) };
91
109
  // Observability callbacks must not terminate the agent or strand work.
92
110
  try {
93
111
  this.options.onEvent?.(correlated);
94
112
  }
95
113
  catch { /* host observer */ }
96
- if (event.type === 'result' && this.active) {
114
+ if (event.type === 'permission_request') {
115
+ this.observe({ type: 'permission_waiting', turnId: correlated.turnId, requestId: event.requestId,
116
+ toolUseId: event.toolUseId, toolName: event.toolName });
117
+ }
118
+ else if (event.type === 'permission_resolved') {
119
+ this.observe({ type: 'permission_resolved', turnId: correlated.turnId, requestId: event.requestId,
120
+ toolUseId: event.toolUseId, behavior: event.behavior });
121
+ }
122
+ if (event.type === 'result' && event.turnId !== undefined && this.active) {
97
123
  if (event.turnId !== this.active.turn.id)
98
124
  throw new Error('ccl-sdk: mismatched turn result');
99
- this.active.resolve(event);
125
+ const entry = this.active;
100
126
  this.active = undefined;
127
+ this.observe({ type: 'turn_finished', turnId: entry.turn.id,
128
+ outcome: event.isError ? entry.cancelRequested ? 'interrupted' : 'failed' : 'succeeded',
129
+ ...(event.isError ? { reason: event.subtype } : {}) });
130
+ entry.resolve(event);
101
131
  this.wake?.();
102
132
  this.notifyDrained();
103
133
  }
@@ -105,7 +135,8 @@ export class Session {
105
135
  throw new Error(event.message);
106
136
  }
107
137
  else if (event.type === 'suspended') {
108
- throw new Error(`ccl-sdk: session suspended; resumeWithDecision(${event.sessionId}) is required`);
138
+ this.fail(new Error(`ccl-sdk: session suspended; resumeWithDecision(${event.sessionId}) is required`), event.origin === 'background' ? 'interrupted' : 'suspended');
139
+ break;
109
140
  }
110
141
  }
111
142
  if (!this.closed)
@@ -118,14 +149,31 @@ export class Session {
118
149
  await this.controls.close();
119
150
  }
120
151
  }
121
- fail(reason) {
152
+ observe(event) {
153
+ try {
154
+ this.options.onLifecycle?.({ ...event, at: Date.now(), pid: this.pid, sessionId: this.sessionId,
155
+ pendingTurns: this.queue.length + (this.active ? 1 : 0) });
156
+ }
157
+ catch { /* host observer */ }
158
+ }
159
+ fail(reason, outcome = 'interrupted') {
160
+ if (this.closed)
161
+ return;
122
162
  const error = reason instanceof Error ? reason : new Error(String(reason));
123
163
  this.closed = true;
124
164
  this.accepting = false;
125
- this.active?.reject(error);
165
+ const active = this.active;
126
166
  this.active = undefined;
127
- for (const entry of this.queue.splice(0))
167
+ const queued = this.queue.splice(0);
168
+ if (active) {
169
+ this.observe({ type: 'turn_finished', turnId: active.turn.id, outcome, reason: error.message });
170
+ active.reject(error);
171
+ }
172
+ for (const entry of queued) {
173
+ this.observe({ type: 'turn_finished', turnId: entry.turn.id, outcome: 'cancelled', reason: error.message });
128
174
  entry.reject(error);
175
+ }
176
+ this.observe({ type: 'session_closed', reason: error.message });
129
177
  this.wake?.();
130
178
  this.notifyDrained();
131
179
  void this.controls.close().catch(() => { });
@@ -137,8 +185,13 @@ export class Session {
137
185
  resolve();
138
186
  }
139
187
  async interrupt() {
140
- if (this.active)
188
+ if (this.active) {
189
+ if (!this.active.cancelRequested) {
190
+ this.active.cancelRequested = true;
191
+ this.observe({ type: 'turn_cancel_requested', turnId: this.active.turn.id });
192
+ }
141
193
  await this.controls.interrupt();
194
+ }
142
195
  }
143
196
  async cancel(id) {
144
197
  if (this.active?.turn.id === id)
@@ -146,7 +199,9 @@ export class Session {
146
199
  const index = this.queue.findIndex(entry => entry.turn.id === id);
147
200
  if (index < 0)
148
201
  return;
149
- this.queue.splice(index, 1)[0].reject(new Error('ccl-sdk: queued turn cancelled'));
202
+ const entry = this.queue.splice(index, 1)[0];
203
+ this.observe({ type: 'turn_finished', turnId: id, outcome: 'cancelled', reason: 'Cancelled before dispatch' });
204
+ entry.reject(new Error('ccl-sdk: queued turn cancelled'));
150
205
  this.notifyDrained();
151
206
  }
152
207
  /** Stop accepting inputs and drain accepted turns, keeping controls alive. */
@@ -314,7 +314,40 @@ export type SDKUserMessage = {
314
314
  };
315
315
  export type SDKEvent = SDKEventPayload & {
316
316
  turnId?: string;
317
+ origin?: 'user' | 'background';
317
318
  };
319
+ /** Host observations, separate from model output and artifact verification.
320
+ * started means the input was written to the CLI, not that a model responded.
321
+ * Active interruptions can have external effects; they are not queued cancellations. */
322
+ export type SessionLifecycleEvent = {
323
+ at: number;
324
+ pid?: number;
325
+ sessionId?: string;
326
+ pendingTurns: number;
327
+ } & ({
328
+ type: 'turn_queued' | 'turn_started' | 'turn_cancel_requested';
329
+ turnId: string;
330
+ } | {
331
+ type: 'turn_finished';
332
+ turnId: string;
333
+ outcome: 'succeeded' | 'failed' | 'cancelled' | 'interrupted' | 'suspended';
334
+ reason?: string;
335
+ } | {
336
+ type: 'permission_waiting';
337
+ turnId?: string;
338
+ requestId: string;
339
+ toolUseId: string;
340
+ toolName: string;
341
+ } | {
342
+ type: 'permission_resolved';
343
+ turnId?: string;
344
+ requestId: string;
345
+ toolUseId: string;
346
+ behavior: 'allow' | 'deny';
347
+ } | {
348
+ type: 'session_closed';
349
+ reason: string;
350
+ });
318
351
  type SDKEventPayload = {
319
352
  type: 'assistant_text';
320
353
  text: string;
@@ -52,3 +52,40 @@ retries saving an already successful result, without rerunning its tools.
52
52
  `cancel` stops an occurrence; delete the durable cron definition as well when
53
53
  future recurring occurrences should cease. A permission wait and an interrupted
54
54
  run remain visible across restarts. Stop does not erase schedules or evidence.
55
+
56
+ When asking the assistant to schedule work, choose its execution scope:
57
+
58
+ - `CronCreate {execution: "session", afterMs: 60000, prompt: "…"}` keeps the
59
+ reminder in the current process. A one-shot print command exits after its turn,
60
+ so this scope does not promise execution after exit.
61
+ - `CronCreate {execution: "background", afterMs: 60000, prompt: "…"}` saves the
62
+ definition durably and starts or reuses the project's detached owner. Calendar
63
+ and anchored interval schedules use the same scope selection.
64
+ - Older calls with only `durable: true` still save the definition without starting
65
+ an owner. Explicit `execution: "session"` cannot be combined with durable storage.
66
+
67
+ The response separates `saved`, `status`, `owner.ready`, canonical `project`,
68
+ `nextRunAt`, `permissions`, and `resultDirectory`. `background_ready` requires a
69
+ live protocol response and ownership of the project scheduler lock. An interactive
70
+ scheduler yields that lock after its pending definition writes finish; private
71
+ session reminders continue in their own process.
72
+
73
+ If the response is `saved_not_running`, retain its task ID and use
74
+ `CronCreate {operation: "handoff", taskId: "…"}` to retry. This keeps the same
75
+ schedule and identity. Do not create a duplicate. A different CLI path or configuration
76
+ already running for the project is reported explicitly; inspect it and stop it
77
+ before handing off with the new configuration. Startup does not inherit the
78
+ creating terminal's permission bypass mode. Existing configured rules apply,
79
+ and unresolved actions remain `waiting_permission` until explicitly approved.
80
+
81
+ The owner uses the creating CLI path, model, configuration inputs and credential
82
+ environment. Protocol, multiplexer and test-session variables are removed. Launch
83
+ configuration is passed through a private pipe, without writing gateway credentials
84
+ into task or owner records. Restarting after a reboot requires starting an owner
85
+ again with valid configuration; no temporary credentials are promoted to permanent
86
+ configuration.
87
+
88
+ `CronList` includes background execution/delivery records and result locations,
89
+ even after a completed one-shot definition has been removed. `ccl daemon status`
90
+ provides the same records from a fresh CLI. Process liveness (`running`) and verified
91
+ scheduler readiness (`owner.ready`) are separate fields.
@@ -0,0 +1,44 @@
1
+ # 1.5.0-alpha.0
2
+
3
+ 本版基于 `1.4.6-beta.0`,交付 AI 指令综合升级。原方案 F1–F14 的修复、真实端到端验证和六原则复核已完成,原协议 Lisa 在 R10 总复核通过。此版为 alpha 预览,不将办公生成的失败样本或既有类型诊断宣称为通过。
4
+
5
+ ## 主要变化
6
+
7
+ - 主助手、子助手的能力说明与实际工具解析共用来源。bare 无 Agent 时可直接工作;零工具角色不再被描述成全工具。内置技能和常规提示不强制调用不存在的工具。
8
+ - 技能目录按完整条目分页,保留中文用途与近邻排除;宿主维护真实加载状态,文件里的标签不再被视为授权凭据。
9
+ - 执行型子助手保留项目约束;审查允许必要的隔离测试产物。配置使用真实路径 owner;Git 写操作按依赖顺序执行;旧 auto/smart 不再用短词覆盖上下文分类。
10
+ - 定时任务支持一次性、固定间隔和带时区的日历计划,包含未来起点与显式时间容差。新建精确任务默认不主动提前或错峰。
11
+ - 明确后台任务接通 daemon 的启动、调度锁和真实就绪检查;保存、执行、权限等待和投递状态分别呈现。关闭创建终端后,结果可从新入口取回。
12
+ - SDK 增加生命周期事件,修复用户输入与自主后台结果并发时的挂起和归属。包内 `examples/assistant/` 提供连续会话、进度、权限恢复和 Office 独立验证的参考宿主。
13
+ - 统一记忆选择 JSON 合同,避免整理 worker 重复检索。视觉输出预算受实际模型能力约束,预算耗尽或截断明确失败;主动取消不再误报缺少正常答案。
14
+ - Office 渲染只清理自己登记且未被修改的过期页面,避免短版文档混入旧尾页。
15
+
16
+ ## 安装
17
+
18
+ ```sh
19
+ npm install --global @margay/ccl-core@1.5.0-alpha.0
20
+ ccl --version
21
+ margay --version
22
+ ```
23
+
24
+ 两个入口均应输出 `1.5.0-alpha.0 (Margay)`。Node 要求仍为 `>=22.19.0`。预览标签为 `alpha`;`latest`、`next`、`stable`、`rc` 保持原值。
25
+
26
+ 此包同时提供 CLI 与 SDK,不会更新 Margay Desktop 内置依赖,也不会自动重启已有进程或安装系统开机服务。若 PATH 前面存在旧包装脚本,用 `type -a ccl margay` 与 `"$(npm prefix -g)/bin/ccl" --version` 核对实际入口。
27
+
28
+ Office 参考流程的制作/渲染依赖及宿主与助手分工见包内 `examples/assistant/README.md` 和 `office/workflow.md`;发布包不自动安装 LibreOffice 或 Python 制作依赖。
29
+
30
+ ## 调度兼容与回退
31
+
32
+ 旧 cron 定义保持原有读取语义,不在读取时静默迁移。首次向已有旧格式文件写入版本 1 计划前,保存原字节到 `.ccl/scheduled_tasks.json.pre-v1.backup`。
33
+
34
+ 旧版本不能完整理解新建的 once/interval 计划。回退前先停止本项目的新调度 owner,导出新任务与执行结果,再恢复旧格式备份;没有旧文件时保留导出并移开新格式文件。之后才安装旧版本。不要同时运行新旧调度 owner;回退程序不会撤销已经执行的业务写入。详见 [调度与 daemon 说明](../context-checkpoints-and-daemon.md)。
35
+
36
+ ## 验证与结果
37
+
38
+ 实现过程通过分片回归、原版反例、公开 CLI/SDK 合同、真实 WezTerm 模型行为、安装包验证及 R1–R10 协议 Lisa 审核。真实后台测试确认创建终端退出后读取库存,未提前访问,新入口取回正确结果。公开参考宿主验证十轮同 PID、排队/steer/取消、断线不重放、权限 pending 后恢复以及错误制品拒绝。
39
+
40
+ 180 次短答和 30 个 Office 长任务的固定版本比较均保留失败及修复成本。Office 最终 29/30 合格,8/30 零修复,后者全部来自常驻会话的后续复用;候选 CLI/SDK 中位耗时增加,不能声称全面提速。这些性能包在比较开始时冻结,早于后续修复,不是本 alpha 最终包的性能成绩。
41
+
42
+ 已知边界:全仓类型检查仍有与基线相同的 11 条诊断;有头十图测试包含记录在案的人工介入;Windows 实机未在本次验证。按需 schema 子树的实验没有改变产品默认配置。AI 生成内容仍需事实和全页审阅,文件存在或模型自报不是交付通过。
43
+
44
+ 仓库内 [完整报告](../ai-instructions-upgrade-final-review-2026-10-20.md)、[网页信息图](../ai-upgrade-explained-2026-10-20.html) 保留修复、证据和审核关系。原始运行证据保留于执行机 `.rll/records/ai-upgrade/`;本版打包、安装与发布回执保留于 `.rll/records/release-1.5.0-alpha.0/`,本地原始证据不随 npm 包分发。
@@ -0,0 +1,23 @@
1
+ # Terminal assistant reference host
2
+
3
+ This runnable host consumes the public `@margay/ccl-core/sdk`. It is included in the npm package; it does not modify the separate Margay GUI.
4
+
5
+ From an installed package (Node 22.19+), run:
6
+
7
+ ```sh
8
+ node node_modules/@margay/ccl-core/examples/assistant/terminal.mjs --cwd /path/to/project
9
+ ```
10
+
11
+ From this checkout, build first (`npm run build`), then run `node examples/assistant/terminal.mjs --cwd /path/to/project`. Normal CLI account/configuration discovery applies. `--model MODEL` selects a model; `--extra-args '["--tools","Read,Write"]'` forwards explicit CLI options. Keep credentials in the existing account configuration, not command arguments.
12
+
13
+ Type a task. `/status` shows turn IDs and separate execution, verification and artifact states. `/steer TEXT` interrupts the active turn and queues the correction before follow-ups. `/cancel` requests active interruption; `/cancel TURN_ID` also removes a queued turn before dispatch. Completed effects are not rolled back.
14
+
15
+ Permission requests show the exact tool and input. `/allow TOOL_ID` or `/deny TOOL_ID` resolves a live request. `/defer TOOL_ID` persists it and suspends execution. Exit with `/exit`, restart with `--resume`, then explicitly allow or deny the saved request. Resume never approves automatically. Existing configured allow/deny rules remain in effect.
16
+
17
+ `/artifact RELATIVE_PATH` reads a project file, records its size and SHA-256, and reports availability. A model result, a readable file, or a model-written `validation.json` does not establish content correctness. Independent verification is recorded separately.
18
+
19
+ State defaults to `PROJECT/.ccl/assistant-client/`; `--state-dir DIR` selects another directory. `state.json` stores session identity, recent turn outcomes and artifact records; `events.jsonl` retains the full journal. CLI transcripts and SDK approval state use their existing stores. These files can contain task content: share them only as appropriate for that project. One client holds the state directory lock; another cannot overwrite it. After a crash, restart with `--resume`. Pending queued work is cancelled and work that may already have executed is marked interrupted; inputs are never replayed automatically. Inspect recorded effects before retrying. A stale lock expires after ten seconds.
20
+
21
+ Queue and dispatch notifications are local SDK observations and make no model request. “已发送” means written to the CLI, not a model response. The actual text stream, tools and request progress are displayed as received. Background events carry `origin: 'background'` and are not assigned to the waiting user turn.
22
+
23
+ For the Office workflow, `/office ["inputs","artifacts"]` sends the packaged workflow location and the supplied directories to the current session. It reads the provided CSV/brief/meeting material and creates editable files using the documented toolchain. `/verify-office ["inputs","artifacts"]` runs the packaged independent Python checker from the host after execution ends. It reports numeric/structural differences and keeps semantic review separate. The checker requires Python 3; generation and full-page rendering dependencies are documented in [the workflow](office/workflow.md). This is an explicit reference-client command; it does not claim registration as a global CLI slash command.