@tagma/sdk 0.2.5 → 0.2.6

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 CHANGED
@@ -118,7 +118,6 @@ pipeline:
118
118
  trigger:
119
119
  type: manual
120
120
  message: "Approve before running"
121
- options: [approve, reject]
122
121
  timeout: 5m
123
122
  completion:
124
123
  type: exit_code
@@ -224,7 +223,6 @@ Track-level `middlewares` apply to all tasks in the track. Setting task-level `m
224
223
  |---|---|---|---|---|
225
224
  | `type` | `"manual"` | Yes | — | Trigger type |
226
225
  | `message` | `string` | No | `"Manual confirmation required for task \"{taskId}\""` | Message shown to the approver |
227
- | `options` | `string[]` | No | — | Choice options (e.g. `[approve, reject]`) |
228
226
  | `timeout` | `string` | No | — | How long to wait for a decision before timing out |
229
227
  | `metadata` | `object` | No | — | Arbitrary metadata passed to the approval gateway |
230
228
 
@@ -296,6 +294,7 @@ Options:
296
294
  - `onEvent` -- callback for real-time `PipelineEvent` updates:
297
295
  - `pipeline_start` — pipeline began; includes `states: ReadonlyMap<taskId, TaskState>` (initial snapshot of all tasks at `waiting`)
298
296
  - `task_status_change` — a task changed status; includes `state: TaskState` (complete snapshot at the time of change: `startedAt` is populated before the `running` event; `result` and `finishedAt` are populated before any terminal-status event)
297
+ - `task_log` — a structured log line was written to `pipeline.log`. Mirrors every `Logger` call (info/warn/error/debug/section/quiet) and carries `{ taskId: string | null, level, timestamp, text }`. `taskId` is non-null for lines tagged with a `[task:<id>]` prefix (or passed explicitly to `section`/`quiet`) and `null` for pipeline-wide messages such as the configuration dump and DAG topology. Use this to stream the full run process into UIs without tailing the log file.
299
298
  - `pipeline_end` — pipeline finished; includes `success: boolean`
300
299
  - `maxLogRuns` -- number of per-run log directories to keep under `<workDir>/.tagma/logs/` (default: 20)
301
300
 
@@ -506,9 +505,31 @@ logger.warn('[track]', 'message'); // console + file
506
505
  logger.error('[track]', 'message'); // console + file
507
506
  logger.debug('[track]', 'message'); // file only
508
507
  logger.section('Title'); // file only — visual separator
509
- logger.quiet(bulkText); // file only — bulk payload
510
- logger.path; // log file path
511
- logger.dir; // run artifact directory
508
+ logger.quiet(bulkText); // file only — bulk payload
509
+ logger.path; // log file path
510
+ logger.dir; // run artifact directory
511
+ ```
512
+
513
+ Pass an optional third argument to stream every appended line out as a
514
+ structured `LogRecord` — `runPipeline` uses this to emit `task_log` events:
515
+
516
+ ```ts
517
+ import { Logger, type LogRecord } from '@tagma/sdk';
518
+
519
+ const logger = new Logger(workDir, runId, (record: LogRecord) => {
520
+ // record = { level, taskId, timestamp, text }
521
+ // level = 'info' | 'warn' | 'error' | 'debug' | 'section' | 'quiet'
522
+ // taskId is extracted from a '[task:<id>]' prefix, or null for untagged lines
523
+ forwardToUI(record);
524
+ });
525
+ ```
526
+
527
+ `section` and `quiet` carry no prefix, so pass an explicit `taskId` when the
528
+ line logically belongs to a task — the extractor cannot infer one otherwise:
529
+
530
+ ```ts
531
+ logger.section(`Task ${taskId}`, taskId);
532
+ logger.quiet(`--- stdout (${taskId}) ---\n${body}\n--- end stdout ---`, taskId);
512
533
  ```
513
534
 
514
535
  ### `tailLines(text: string, n: number): string`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tagma/sdk",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -43,34 +43,23 @@ export function attachStdinApprovalAdapter(gateway: ApprovalGateway): StdinAppro
43
43
  // If the request was already resolved by another path while queued, skip it.
44
44
  if (!gateway.pending().some((p) => p.id === req.id)) continue;
45
45
 
46
- const optionsStr = req.options.join(' / ');
47
46
  process.stdout.write(
48
47
  `\n[APPROVAL REQUIRED] ${req.message}\n` +
49
48
  ` id: ${req.id}\n` +
50
49
  ` task: ${req.taskId}${req.trackId ? ` (track: ${req.trackId})` : ''}\n` +
51
- ` options: ${optionsStr}\n` +
52
- ` > `,
50
+ ` approve / reject > `,
53
51
  );
54
52
 
55
53
  const input = (await readOneLine()).trim().toLowerCase();
56
54
 
57
55
  const approveAliases = new Set(['approve', 'yes', 'y', 'ok', 'true', '1']);
58
56
  const rejectAliases = new Set(['reject', 'no', 'n', 'deny', 'false', '0']);
59
- const matchedOption = req.options.find((o) => o.toLowerCase() === input);
60
57
 
61
- if (matchedOption) {
62
- const isReject = rejectAliases.has(matchedOption.toLowerCase());
63
- gateway.resolve(req.id, {
64
- outcome: isReject ? 'rejected' : 'approved',
65
- choice: matchedOption,
66
- actor: 'cli',
67
- });
68
- } else if (approveAliases.has(input)) {
69
- gateway.resolve(req.id, { outcome: 'approved', choice: input, actor: 'cli' });
58
+ if (approveAliases.has(input)) {
59
+ gateway.resolve(req.id, { outcome: 'approved', actor: 'cli' });
70
60
  } else if (rejectAliases.has(input)) {
71
61
  gateway.resolve(req.id, {
72
62
  outcome: 'rejected',
73
- choice: input,
74
63
  actor: 'cli',
75
64
  reason: 'user rejected via CLI',
76
65
  });
@@ -16,7 +16,7 @@ import type { ApprovalGateway, ApprovalEvent } from '../approval';
16
16
  //
17
17
  // Protocol — client → server:
18
18
  // { type: 'resolve', approvalId: string, outcome: 'approved'|'rejected',
19
- // choice?: string, actor?: string, reason?: string }
19
+ // actor?: string, reason?: string }
20
20
 
21
21
  export interface WebSocketApprovalAdapterOptions {
22
22
  port?: number; // default: 3000
@@ -123,7 +123,6 @@ export function attachWebSocketApprovalAdapter(
123
123
 
124
124
  const ok = gateway.resolve(msg.approvalId, {
125
125
  outcome: msg.outcome,
126
- choice: msg.choice,
127
126
  actor: msg.actor ?? 'websocket',
128
127
  reason: msg.reason,
129
128
  });
@@ -159,7 +158,6 @@ interface ResolveMessage {
159
158
  type: 'resolve';
160
159
  approvalId: string;
161
160
  outcome: 'approved' | 'rejected';
162
- choice?: string;
163
161
  actor?: string;
164
162
  reason?: string;
165
163
  }
package/src/approval.ts CHANGED
@@ -16,9 +16,6 @@ export type {
16
16
  ApprovalListener, ApprovalGateway,
17
17
  } from '@tagma/types';
18
18
 
19
- // Default options presented to the approver when the caller does not specify any.
20
- const DEFAULT_APPROVAL_OPTIONS = ['approve', 'reject'] as const;
21
-
22
19
  // ═══ Default In-Memory Implementation ═══
23
20
 
24
21
  interface PendingEntry {
@@ -32,7 +29,7 @@ export class InMemoryApprovalGateway implements ApprovalGateway {
32
29
  private readonly listeners = new Set<ApprovalListener>();
33
30
 
34
31
  request(
35
- req: Omit<ApprovalRequest, 'id' | 'createdAt' | 'options'> & { options?: readonly string[] },
32
+ req: Omit<ApprovalRequest, 'id' | 'createdAt'>,
36
33
  ): Promise<ApprovalDecision> {
37
34
  const full: ApprovalRequest = {
38
35
  id: randomUUID(),
@@ -40,7 +37,6 @@ export class InMemoryApprovalGateway implements ApprovalGateway {
40
37
  taskId: req.taskId,
41
38
  trackId: req.trackId,
42
39
  message: req.message,
43
- options: req.options && req.options.length > 0 ? req.options : DEFAULT_APPROVAL_OPTIONS,
44
40
  timeoutMs: req.timeoutMs,
45
41
  metadata: req.metadata,
46
42
  };
@@ -80,7 +76,6 @@ export class InMemoryApprovalGateway implements ApprovalGateway {
80
76
  const full: ApprovalDecision = {
81
77
  approvalId,
82
78
  outcome: decision.outcome,
83
- choice: decision.choice,
84
79
  actor: decision.actor,
85
80
  reason: decision.reason,
86
81
  decidedAt: nowISO(),
package/src/engine.ts CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  buildPipelineCompleteContext, buildPipelineErrorContext,
17
17
  type PipelineInfo, type TrackInfo, type TaskInfo,
18
18
  } from './hooks';
19
- import { Logger, tailLines, clip } from './logger';
19
+ import { Logger, tailLines, clip, type LogLevel } from './logger';
20
20
  import { InMemoryApprovalGateway, type ApprovalGateway } from './approval';
21
21
 
22
22
  // ═══ Preflight Validation ═══
@@ -115,7 +115,15 @@ export interface EngineResult {
115
115
  export type PipelineEvent =
116
116
  | { readonly type: 'task_status_change'; readonly taskId: string; readonly status: TaskStatus; readonly prevStatus: TaskStatus; readonly runId: string; readonly state: TaskState }
117
117
  | { readonly type: 'pipeline_start'; readonly runId: string; readonly states: ReadonlyMap<string, TaskState> }
118
- | { readonly type: 'pipeline_end'; readonly runId: string; readonly success: boolean };
118
+ | { readonly type: 'pipeline_end'; readonly runId: string; readonly success: boolean }
119
+ /**
120
+ * Fine-grained log line emitted alongside every write to pipeline.log.
121
+ * Consumers use this to stream the full run process into UIs without
122
+ * tailing the log file. `taskId` is non-null for task-scoped lines and
123
+ * null for pipeline-wide messages (e.g. configuration dumps, DAG
124
+ * topology, pipeline start/end).
125
+ */
126
+ | { readonly type: 'task_log'; readonly runId: string; readonly taskId: string | null; readonly level: LogLevel; readonly timestamp: string; readonly text: string };
119
127
 
120
128
  export interface RunPipelineOptions {
121
129
  readonly approvalGateway?: ApprovalGateway;
@@ -160,7 +168,19 @@ export async function runPipeline(
160
168
 
161
169
  const startedAt = nowISO();
162
170
  const pipelineInfo: PipelineInfo = { name: config.name, run_id: runId, started_at: startedAt };
163
- const log = new Logger(workDir, runId);
171
+ // Forward every structured log line to subscribers as task_log events.
172
+ // Reading options.onEvent inside the callback (vs. capturing it once) keeps
173
+ // the SDK behavior correct if callers pass a fresh onEvent on each run.
174
+ const log = new Logger(workDir, runId, (record) => {
175
+ options.onEvent?.({
176
+ type: 'task_log',
177
+ runId,
178
+ taskId: record.taskId,
179
+ level: record.level,
180
+ timestamp: record.timestamp,
181
+ text: record.text,
182
+ });
183
+ });
164
184
  log.info('[pipeline]', `start "${config.name}" run_id=${runId}`);
165
185
 
166
186
  // File-only: dump the resolved pipeline shape + DAG topology for post-mortem.
@@ -352,7 +372,7 @@ export async function runPipeline(
352
372
  const task = node.task;
353
373
  const track = node.track;
354
374
 
355
- log.section(`Task ${taskId}`);
375
+ log.section(`Task ${taskId}`, taskId);
356
376
  log.debug(`[task:${taskId}]`,
357
377
  `type=${task.prompt ? 'ai' : 'cmd'} track=${track.id} deps=[${node.dependsOn.join(', ') || '(root)'}]`);
358
378
 
@@ -469,7 +489,7 @@ export async function runPipeline(
469
489
  }
470
490
  log.debug(`[task:${taskId}]`,
471
491
  `prompt: ${originalLen} chars (final: ${prompt.length} chars)`);
472
- log.quiet(`--- prompt (final) ---\n${clip(prompt)}\n--- end prompt ---`);
492
+ log.quiet(`--- prompt (final) ---\n${clip(prompt)}\n--- end prompt ---`, taskId);
473
493
 
474
494
  const enrichedTask: TaskConfig = { ...task, prompt };
475
495
  const driverCtx: DriverContext = {
@@ -565,10 +585,10 @@ export async function runPipeline(
565
585
  log.debug(`[task:${taskId}]`, `wrote stderr: ${result.stderrPath}`);
566
586
  }
567
587
  if (result.stdout) {
568
- log.quiet(`--- stdout (${taskId}) ---\n${clip(result.stdout)}\n--- end stdout ---`);
588
+ log.quiet(`--- stdout (${taskId}) ---\n${clip(result.stdout)}\n--- end stdout ---`, taskId);
569
589
  }
570
590
  if (result.stderr) {
571
- log.quiet(`--- stderr (${taskId}) ---\n${clip(result.stderr)}\n--- end stderr ---`);
591
+ log.quiet(`--- stderr (${taskId}) ---\n${clip(result.stderr)}\n--- end stderr ---`, taskId);
572
592
  }
573
593
  if (task.completion) {
574
594
  log.debug(`[task:${taskId}]`,
package/src/logger.ts CHANGED
@@ -1,112 +1,164 @@
1
- import { resolve, dirname } from 'node:path';
2
- import { mkdirSync, appendFileSync, writeFileSync } from 'node:fs';
3
-
4
- /**
5
- * Dual-channel logger.
6
- *
7
- * - `info/warn/error` console AND file (brief, user-visible events)
8
- * - `debug` → file ONLY (verbose diagnostics)
9
- * - `section` file ONLY (visual separators)
10
- * - `quiet` → file ONLY (bulk payload like full stdout dumps)
11
- *
12
- * Log file path: <workDir>/.tagma/logs/<runId>/pipeline.log (one file per pipeline run,
13
- * truncated on construction).
14
- */
15
- export class Logger {
16
- private readonly filePath: string;
17
- private readonly runDir: string;
18
-
19
- constructor(workDir: string, runId: string) {
20
- this.runDir = resolve(workDir, '.tagma', 'logs', runId);
21
- this.filePath = resolve(this.runDir, 'pipeline.log');
22
- mkdirSync(dirname(this.filePath), { recursive: true });
23
- writeFileSync(
24
- this.filePath,
25
- `# Pipeline run ${runId} @ ${new Date().toISOString()}\n` +
26
- `# Host: ${process.platform} ${process.arch} Bun: ${process.versions.bun ?? 'n/a'}\n` +
27
- `# Work dir: ${workDir}\n\n`,
28
- );
29
- }
30
-
31
- info(prefix: string, message: string): void {
32
- const line = `${timestamp()} ${prefix} ${message}`;
33
- console.log(line);
34
- this.append(line);
35
- }
36
-
37
- warn(prefix: string, message: string): void {
38
- const line = `${timestamp()} ${prefix} WARN: ${message}`;
39
- console.warn(line);
40
- this.append(line);
41
- }
42
-
43
- error(prefix: string, message: string): void {
44
- const line = `${timestamp()} ${prefix} ERROR: ${message}`;
45
- console.error(line);
46
- this.append(line);
47
- }
48
-
49
- /** File-only diagnostic log line. */
50
- debug(prefix: string, message: string): void {
51
- this.append(`${timestamp()} ${prefix} DEBUG: ${message}`);
52
- }
53
-
54
- /** File-only visual separator with title. */
55
- section(title: string): void {
56
- this.append(`\n━━━ ${title} ━━━`);
57
- }
58
-
59
- /** File-only bulk payload (e.g. full stdout / stderr dumps). */
60
- quiet(message: string): void {
61
- this.append(message);
62
- }
63
-
64
- private append(line: string): void {
65
- try {
66
- appendFileSync(this.filePath, line.endsWith('\n') ? line : line + '\n');
67
- } catch {
68
- // Swallow log write failures; engine correctness shouldn't depend on logging.
69
- }
70
- }
71
-
72
- get path(): string {
73
- return this.filePath;
74
- }
75
-
76
- /** Directory that holds all artifacts for this run (pipeline.log, *.stderr, etc.). */
77
- get dir(): string {
78
- return this.runDir;
79
- }
80
- }
81
-
82
- function timestamp(): string {
83
- const d = new Date();
84
- const hh = String(d.getHours()).padStart(2, '0');
85
- const mm = String(d.getMinutes()).padStart(2, '0');
86
- const ss = String(d.getSeconds()).padStart(2, '0');
87
- const ms = String(d.getMilliseconds()).padStart(3, '0');
88
- return `${hh}:${mm}:${ss}.${ms}`;
89
- }
90
-
91
- /** Return the last `n` non-empty lines of `text`, joined with newlines. */
92
- export function tailLines(text: string, n: number): string {
93
- if (!text) return '';
94
- const lines = text.split(/\r?\n/).filter(l => l.length > 0);
95
- return lines.slice(-n).join('\n');
96
- }
97
-
98
- /**
99
- * Truncate a blob to at most `maxBytes` UTF-8 bytes for log embedding,
100
- * appending a marker when truncation occurred.
101
- * Uses TextEncoder so CJK and emoji (multi-byte) characters are counted correctly.
102
- */
103
- export function clip(text: string, maxBytes = 16 * 1024): string {
104
- if (!text) return '';
105
- const encoder = new TextEncoder();
106
- const bytes = encoder.encode(text);
107
- if (bytes.length <= maxBytes) return text;
108
- const omittedBytes = bytes.length - maxBytes;
109
- // TextDecoder handles partial code-point boundaries safely (replacement char insertion)
110
- const truncated = new TextDecoder().decode(bytes.slice(0, maxBytes));
111
- return truncated + `\n…[truncated ${omittedBytes} bytes]`;
112
- }
1
+ import { resolve, dirname } from 'node:path';
2
+ import { mkdirSync, appendFileSync, writeFileSync } from 'node:fs';
3
+
4
+ /**
5
+ * Structured record emitted for every log line. Consumers (e.g. the editor
6
+ * server) use this to stream process-level detail into UIs alongside the
7
+ * on-disk pipeline.log. `taskId` is extracted from a `[task:<id>]` prefix
8
+ * when the call site passes one, or overridden explicitly via the optional
9
+ * `taskId` argument on `section`/`quiet` (which carry no prefix).
10
+ */
11
+ export type LogLevel = 'info' | 'warn' | 'error' | 'debug' | 'section' | 'quiet';
12
+
13
+ export interface LogRecord {
14
+ readonly level: LogLevel;
15
+ readonly taskId: string | null;
16
+ readonly timestamp: string;
17
+ readonly text: string;
18
+ }
19
+
20
+ export type LogListener = (record: LogRecord) => void;
21
+
22
+ const TASK_PREFIX_RE = /\[task:([^\]]+)\]/;
23
+
24
+ function taskIdFromPrefix(prefix: string): string | null {
25
+ const m = TASK_PREFIX_RE.exec(prefix);
26
+ return m ? m[1] : null;
27
+ }
28
+
29
+ /**
30
+ * Dual-channel logger.
31
+ *
32
+ * - `info/warn/error` → console AND file (brief, user-visible events)
33
+ * - `debug` → file ONLY (verbose diagnostics)
34
+ * - `section` → file ONLY (visual separators)
35
+ * - `quiet` → file ONLY (bulk payload like full stdout dumps)
36
+ *
37
+ * Log file path: <workDir>/.tagma/logs/<runId>/pipeline.log (one file per pipeline run,
38
+ * truncated on construction). Every line is also forwarded to the optional
39
+ * `onLine` callback as a structured `LogRecord`, so callers that want to
40
+ * stream the run process over IPC/SSE don't need to tail the file.
41
+ */
42
+ export class Logger {
43
+ private readonly filePath: string;
44
+ private readonly runDir: string;
45
+ private readonly onLine: LogListener | null;
46
+
47
+ constructor(workDir: string, runId: string, onLine?: LogListener) {
48
+ this.runDir = resolve(workDir, '.tagma', 'logs', runId);
49
+ this.filePath = resolve(this.runDir, 'pipeline.log');
50
+ this.onLine = onLine ?? null;
51
+ mkdirSync(dirname(this.filePath), { recursive: true });
52
+ writeFileSync(
53
+ this.filePath,
54
+ `# Pipeline run ${runId} @ ${new Date().toISOString()}\n` +
55
+ `# Host: ${process.platform} ${process.arch} Bun: ${process.versions.bun ?? 'n/a'}\n` +
56
+ `# Work dir: ${workDir}\n\n`,
57
+ );
58
+ }
59
+
60
+ info(prefix: string, message: string): void {
61
+ const ts = timestamp();
62
+ const line = `${ts} ${prefix} ${message}`;
63
+ console.log(line);
64
+ this.emit('info', ts, line, taskIdFromPrefix(prefix));
65
+ this.append(line);
66
+ }
67
+
68
+ warn(prefix: string, message: string): void {
69
+ const ts = timestamp();
70
+ const line = `${ts} ${prefix} WARN: ${message}`;
71
+ console.warn(line);
72
+ this.emit('warn', ts, line, taskIdFromPrefix(prefix));
73
+ this.append(line);
74
+ }
75
+
76
+ error(prefix: string, message: string): void {
77
+ const ts = timestamp();
78
+ const line = `${ts} ${prefix} ERROR: ${message}`;
79
+ console.error(line);
80
+ this.emit('error', ts, line, taskIdFromPrefix(prefix));
81
+ this.append(line);
82
+ }
83
+
84
+ /** File-only diagnostic log line. */
85
+ debug(prefix: string, message: string): void {
86
+ const ts = timestamp();
87
+ const line = `${ts} ${prefix} DEBUG: ${message}`;
88
+ this.emit('debug', ts, line, taskIdFromPrefix(prefix));
89
+ this.append(line);
90
+ }
91
+
92
+ /** File-only visual separator with title. */
93
+ section(title: string, taskId?: string | null): void {
94
+ const ts = timestamp();
95
+ const text = `\n━━━ ${title} ━━━`;
96
+ this.emit('section', ts, text, taskId ?? null);
97
+ this.append(text);
98
+ }
99
+
100
+ /** File-only bulk payload (e.g. full stdout / stderr dumps). */
101
+ quiet(message: string, taskId?: string | null): void {
102
+ const ts = timestamp();
103
+ this.emit('quiet', ts, message, taskId ?? null);
104
+ this.append(message);
105
+ }
106
+
107
+ private append(line: string): void {
108
+ try {
109
+ appendFileSync(this.filePath, line.endsWith('\n') ? line : line + '\n');
110
+ } catch {
111
+ // Swallow log write failures; engine correctness shouldn't depend on logging.
112
+ }
113
+ }
114
+
115
+ private emit(level: LogLevel, ts: string, text: string, taskId: string | null): void {
116
+ if (!this.onLine) return;
117
+ try {
118
+ this.onLine({ level, taskId, timestamp: ts, text });
119
+ } catch {
120
+ // Never let a listener error derail the pipeline.
121
+ }
122
+ }
123
+
124
+ get path(): string {
125
+ return this.filePath;
126
+ }
127
+
128
+ /** Directory that holds all artifacts for this run (pipeline.log, *.stderr, etc.). */
129
+ get dir(): string {
130
+ return this.runDir;
131
+ }
132
+ }
133
+
134
+ function timestamp(): string {
135
+ const d = new Date();
136
+ const hh = String(d.getHours()).padStart(2, '0');
137
+ const mm = String(d.getMinutes()).padStart(2, '0');
138
+ const ss = String(d.getSeconds()).padStart(2, '0');
139
+ const ms = String(d.getMilliseconds()).padStart(3, '0');
140
+ return `${hh}:${mm}:${ss}.${ms}`;
141
+ }
142
+
143
+ /** Return the last `n` non-empty lines of `text`, joined with newlines. */
144
+ export function tailLines(text: string, n: number): string {
145
+ if (!text) return '';
146
+ const lines = text.split(/\r?\n/).filter(l => l.length > 0);
147
+ return lines.slice(-n).join('\n');
148
+ }
149
+
150
+ /**
151
+ * Truncate a blob to at most `maxBytes` UTF-8 bytes for log embedding,
152
+ * appending a marker when truncation occurred.
153
+ * Uses TextEncoder so CJK and emoji (multi-byte) characters are counted correctly.
154
+ */
155
+ export function clip(text: string, maxBytes = 16 * 1024): string {
156
+ if (!text) return '';
157
+ const encoder = new TextEncoder();
158
+ const bytes = encoder.encode(text);
159
+ if (bytes.length <= maxBytes) return text;
160
+ const omittedBytes = bytes.length - maxBytes;
161
+ // TextDecoder handles partial code-point boundaries safely (replacement char insertion)
162
+ const truncated = new TextDecoder().decode(bytes.slice(0, maxBytes));
163
+ return truncated + `\n…[truncated ${omittedBytes} bytes]`;
164
+ }
@@ -1,45 +1,45 @@
1
- import { basename } from 'path';
2
- import type { MiddlewarePlugin, MiddlewareContext } from '../types';
3
- import { validatePath } from '../utils';
4
-
5
- export const StaticContextMiddleware: MiddlewarePlugin = {
6
- name: 'static_context',
7
- schema: {
8
- description: 'Prepend a reference file to the prompt as static context.',
9
- fields: {
10
- file: {
11
- type: 'path',
12
- required: true,
13
- description: 'Path to the reference file (relative to workDir or absolute).',
14
- placeholder: 'docs/spec.md',
15
- },
16
- label: {
17
- type: 'string',
18
- description: 'Header shown before the content. Defaults to "Reference: <basename>".',
19
- placeholder: 'Reference: spec.md',
20
- },
21
- },
22
- },
23
-
24
- async enhance(
25
- prompt: string,
26
- config: Record<string, unknown>,
27
- ctx: MiddlewareContext,
28
- ): Promise<string> {
29
- const filePath = config.file as string;
30
- if (!filePath) throw new Error('static_context middleware: "file" is required');
31
-
32
- const safePath = validatePath(filePath, ctx.workDir);
33
- const file = Bun.file(safePath);
34
-
35
- if (!(await file.exists())) {
36
- console.warn(`static_context: file ${filePath} not found, skipping`);
37
- return prompt;
38
- }
39
-
40
- const content = await file.text();
41
- const label = (config.label as string) ?? `Reference: ${basename(filePath)}`;
42
-
43
- return `[${label}]\n${content}\n\n[Task]\n${prompt}`;
44
- },
45
- };
1
+ import { basename } from 'path';
2
+ import type { MiddlewarePlugin, MiddlewareContext } from '../types';
3
+ import { validatePath } from '../utils';
4
+
5
+ export const StaticContextMiddleware: MiddlewarePlugin = {
6
+ name: 'static_context',
7
+ schema: {
8
+ description: 'Prepend a reference file to the prompt as static context.',
9
+ fields: {
10
+ file: {
11
+ type: 'path',
12
+ required: true,
13
+ description: 'Path to the reference file (relative to workDir or absolute).',
14
+ placeholder: 'docs/spec.md',
15
+ },
16
+ label: {
17
+ type: 'string',
18
+ description: 'Header shown before the content. Defaults to "Reference: <basename>".',
19
+ placeholder: 'Reference: spec.md',
20
+ },
21
+ },
22
+ },
23
+
24
+ async enhance(
25
+ prompt: string,
26
+ config: Record<string, unknown>,
27
+ ctx: MiddlewareContext,
28
+ ): Promise<string> {
29
+ const filePath = config.file as string;
30
+ if (!filePath) throw new Error('static_context middleware: "file" is required');
31
+
32
+ const safePath = validatePath(filePath, ctx.workDir);
33
+ const file = Bun.file(safePath);
34
+
35
+ if (!(await file.exists())) {
36
+ console.warn(`static_context: file ${filePath} not found, skipping`);
37
+ return prompt;
38
+ }
39
+
40
+ const content = await file.text();
41
+ const label = (config.label as string) ?? `Reference: ${basename(filePath)}`;
42
+
43
+ return `[${label}]\n${content}\n\n[Task]\n${prompt}`;
44
+ },
45
+ };
package/src/sdk.ts CHANGED
@@ -63,6 +63,7 @@ export type { WebSocketApprovalAdapter, WebSocketApprovalAdapterOptions } from '
63
63
 
64
64
  // ── Logger ──
65
65
  export { Logger, tailLines, clip } from './logger';
66
+ export type { LogRecord, LogLevel, LogListener } from './logger';
66
67
 
67
68
  // ── Hook context types (useful for frontend display) ──
68
69
  export type { HookResult, PipelineInfo, TrackInfo, TaskInfo } from './hooks';
@@ -16,11 +16,6 @@ export const ManualTrigger: TriggerPlugin = {
16
16
  description: 'Maximum wait time (e.g. 10m). Omit or 0 to wait indefinitely.',
17
17
  placeholder: '10m',
18
18
  },
19
- options: {
20
- type: 'string',
21
- description: 'Comma-separated list of choices offered to the approver (e.g. "yes,no,defer").',
22
- placeholder: 'yes,no',
23
- },
24
19
  },
25
20
  },
26
21
 
@@ -28,9 +23,6 @@ export const ManualTrigger: TriggerPlugin = {
28
23
  const message =
29
24
  (config.message as string | undefined) ?? `Manual confirmation required for task "${ctx.taskId}"`;
30
25
  const timeoutMs = config.timeout ? parseDuration(config.timeout as string) : 0;
31
- const options = Array.isArray(config.options)
32
- ? (config.options as unknown[]).map(String)
33
- : undefined;
34
26
  const metadata =
35
27
  config.metadata && typeof config.metadata === 'object'
36
28
  ? (config.metadata as Record<string, unknown>)
@@ -40,7 +32,6 @@ export const ManualTrigger: TriggerPlugin = {
40
32
  taskId: ctx.taskId,
41
33
  trackId: ctx.trackId,
42
34
  message,
43
- options,
44
35
  timeoutMs,
45
36
  metadata,
46
37
  });
@@ -66,7 +57,7 @@ export const ManualTrigger: TriggerPlugin = {
66
57
 
67
58
  switch (decision.outcome) {
68
59
  case 'approved':
69
- return { confirmed: true, approvalId: decision.approvalId, choice: decision.choice, actor: decision.actor };
60
+ return { confirmed: true, approvalId: decision.approvalId, actor: decision.actor };
70
61
  case 'rejected':
71
62
  throw new Error(
72
63
  `Manual trigger rejected by ${decision.actor ?? 'user'}` +