cccc-sdk 0.4.3 → 0.4.4

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/dist/client.js CHANGED
@@ -1,8 +1,12 @@
1
1
  /**
2
2
  * CCCC SDK client
3
3
  */
4
- import { DaemonAPIError, IncompatibleDaemonError, } from './errors.js';
4
+ import { DaemonAPIError, IncompatibleDaemonError, ErrorCodes, } from './errors.js';
5
+ import { isStreamEvent } from './types.js';
5
6
  import { discoverEndpoint, callDaemon, openEventsStream, readLines, } from './transport.js';
7
+ function compactRecord(input) {
8
+ return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
9
+ }
6
10
  /**
7
11
  * Client for communicating with the CCCC daemon over IPC (Unix socket or TCP).
8
12
  *
@@ -50,22 +54,23 @@ export class CCCCClient {
50
54
  args: args ?? {},
51
55
  };
52
56
  const response = await callDaemon(this._endpoint, request, this._timeoutMs);
53
- if (!response.ok && response.error) {
54
- throw new DaemonAPIError(response.error.code ?? 'error', response.error.message ?? 'daemon error', response.error.details ?? {}, response);
57
+ if (!response.ok) {
58
+ throw new DaemonAPIError(response.error?.code ?? 'error', response.error?.message ?? 'daemon error', response.error?.details ?? {}, response);
55
59
  }
56
60
  return response;
57
61
  }
58
62
  /**
59
63
  * Send an IPC request and return only the result payload.
64
+ * @typeParam T - Expected result type (defaults to `Record<string, unknown>`).
60
65
  * @param op - The IPC operation name.
61
66
  * @param args - Operation arguments.
62
- * @returns The `result` field from the daemon response (empty object if absent).
67
+ * @returns The `result` field from the daemon response, cast to `T`.
63
68
  * @throws {DaemonAPIError} If the daemon returns an error.
64
69
  * @throws {DaemonUnavailableError} If the connection fails.
65
70
  */
66
71
  async call(op, args) {
67
72
  const response = await this.callRaw(op, args);
68
- return response.result ?? {};
73
+ return (response.result ?? {});
69
74
  }
70
75
  /**
71
76
  * Assert that the connected daemon meets the caller's compatibility requirements.
@@ -77,12 +82,8 @@ export class CCCCClient {
77
82
  */
78
83
  async assertCompatible(options = {}) {
79
84
  const pingResult = await this.ping();
80
- const rawIpcV = pingResult['ipc_v'];
81
- const ipcV = typeof rawIpcV === 'number' ? rawIpcV : 0;
82
- const rawCaps = pingResult['capabilities'];
83
- const capabilities = (rawCaps !== null && typeof rawCaps === 'object' && !Array.isArray(rawCaps))
84
- ? rawCaps
85
- : {};
85
+ const ipcV = pingResult.ipc_v ?? 0;
86
+ const capabilities = pingResult.capabilities ?? {};
86
87
  // Check IPC version
87
88
  const requiredV = options.requireIpcV ?? 1;
88
89
  if (ipcV < requiredV) {
@@ -103,7 +104,7 @@ export class CCCCClient {
103
104
  await this.callRaw(op, {});
104
105
  }
105
106
  catch (e) {
106
- if (e instanceof DaemonAPIError && e.code === 'unknown_op') {
107
+ if (e instanceof DaemonAPIError && e.code === ErrorCodes.UNKNOWN_OP) {
107
108
  throw new IncompatibleDaemonError(`Operation not supported: ${op}`);
108
109
  }
109
110
  // Other errors (e.g. missing_group_id) imply the operation exists.
@@ -207,7 +208,7 @@ export class CCCCClient {
207
208
  async groupAutomationManage(options) {
208
209
  const actions = options.actions;
209
210
  if (actions.length === 0) {
210
- throw new DaemonAPIError('invalid_args', 'groupAutomationManage requires a non-empty actions array', {});
211
+ throw new DaemonAPIError(ErrorCodes.INVALID_ARGS, 'groupAutomationManage requires a non-empty actions array', {});
211
212
  }
212
213
  const args = {
213
214
  group_id: options.groupId,
@@ -314,10 +315,25 @@ export class CCCCClient {
314
315
  return this.call('actor_remove', { group_id: groupId, actor_id: actorId, by });
315
316
  }
316
317
  /**
317
- * Start actor
318
+ * Start actor.
319
+ * @param groupId - Group ID.
320
+ * @param actorId - Actor ID.
321
+ * @param options - Optional settings. Pass `{ idempotent: true }` to suppress errors when the actor is already running.
322
+ * @returns The daemon result.
323
+ * @throws {DaemonAPIError} On error (unless idempotent and actor already running).
318
324
  */
319
- async actorStart(groupId, actorId, by = 'user') {
320
- return this.call('actor_start', { group_id: groupId, actor_id: actorId, by });
325
+ async actorStart(groupId, actorId, options) {
326
+ const by = typeof options === 'string' ? options : (options?.by ?? 'user');
327
+ const idempotent = typeof options === 'object' && options?.idempotent === true;
328
+ try {
329
+ return await this.call('actor_start', { group_id: groupId, actor_id: actorId, by });
330
+ }
331
+ catch (e) {
332
+ if (idempotent && e instanceof DaemonAPIError && e.code === ErrorCodes.CONFLICT) {
333
+ return {};
334
+ }
335
+ throw e;
336
+ }
321
337
  }
322
338
  /**
323
339
  * Stop actor
@@ -466,48 +482,35 @@ export class CCCCClient {
466
482
  /**
467
483
  * Search the capability registry for one group/caller scope.
468
484
  */
469
- async capabilitySearch(options) {
470
- const args = {
485
+ async capabilitySearch(options = {}) {
486
+ return this.call('capability_search', compactRecord({
471
487
  group_id: options.groupId,
472
- by: options.by ?? 'user',
473
- };
474
- if (options.actorId)
475
- args['actor_id'] = options.actorId;
476
- if (options.query)
477
- args['query'] = options.query;
478
- if (options.kind !== undefined)
479
- args['kind'] = options.kind;
480
- if (options.sourceId)
481
- args['source_id'] = options.sourceId;
482
- if (options.trustTier)
483
- args['trust_tier'] = options.trustTier;
484
- if (options.qualificationStatus !== undefined)
485
- args['qualification_status'] = options.qualificationStatus;
486
- if (options.includeExternal !== undefined)
487
- args['include_external'] = options.includeExternal;
488
- if (options.limit !== undefined)
489
- args['limit'] = options.limit;
490
- return this.call('capability_search', args);
488
+ by: options.by,
489
+ actor_id: options.actorId,
490
+ query: options.query,
491
+ kind: options.kind,
492
+ source_id: options.sourceId,
493
+ trust_tier: options.trustTier,
494
+ qualification_status: options.qualificationStatus,
495
+ include_external: options.includeExternal,
496
+ limit: options.limit,
497
+ }));
491
498
  }
492
499
  /**
493
500
  * Enable or disable a capability.
494
501
  */
495
502
  async capabilityEnable(options) {
496
- const args = {
503
+ return this.call('capability_enable', compactRecord({
497
504
  group_id: options.groupId,
498
505
  capability_id: options.capabilityId,
499
- scope: options.scope ?? 'session',
500
- enabled: options.enabled ?? true,
501
- cleanup: options.cleanup ?? false,
502
- by: options.by ?? 'user',
503
- };
504
- if (options.reason)
505
- args['reason'] = options.reason;
506
- if (options.ttlSeconds !== undefined)
507
- args['ttl_seconds'] = options.ttlSeconds;
508
- if (options.actorId)
509
- args['actor_id'] = options.actorId;
510
- return this.call('capability_enable', args);
506
+ scope: options.scope,
507
+ enabled: options.enabled,
508
+ cleanup: options.cleanup,
509
+ by: options.by,
510
+ reason: options.reason,
511
+ ttl_seconds: options.ttlSeconds,
512
+ actor_id: options.actorId,
513
+ }));
511
514
  }
512
515
  /**
513
516
  * Block or unblock a capability.
@@ -531,14 +534,15 @@ export class CCCCClient {
531
534
  /**
532
535
  * Read effective capability exposure for one caller scope.
533
536
  */
534
- async capabilityState(options) {
535
- const args = {
536
- group_id: options.groupId,
537
- by: options.by ?? 'user',
538
- };
539
- if (options.actorId)
540
- args['actor_id'] = options.actorId;
541
- return this.call('capability_state', args);
537
+ async capabilityState(options = {}, actorId) {
538
+ const args = typeof options === 'string'
539
+ ? { group_id: options, actor_id: actorId }
540
+ : {
541
+ group_id: options.groupId,
542
+ by: options.by,
543
+ actor_id: options.actorId,
544
+ };
545
+ return this.call('capability_state', compactRecord(args));
542
546
  }
543
547
  /**
544
548
  * Read capability allowlist default, overlay, and effective snapshots.
@@ -645,7 +649,7 @@ export class CCCCClient {
645
649
  /**
646
650
  * Send a chat message to a group.
647
651
  * @param options - Message content, recipients, and priority.
648
- * @returns The daemon result (includes event id).
652
+ * @returns The created event and optional ack event.
649
653
  * @throws {DaemonAPIError} On invalid group, missing permissions, etc.
650
654
  */
651
655
  async send(options) {
@@ -663,7 +667,8 @@ export class CCCCClient {
663
667
  return this.call('send', args);
664
668
  }
665
669
  /**
666
- * Send message across groups
670
+ * Send message across groups.
671
+ * @returns The created event and optional ack event.
667
672
  */
668
673
  async sendCrossGroup(options) {
669
674
  const args = {
@@ -679,7 +684,30 @@ export class CCCCClient {
679
684
  return this.call('send_cross_group', args);
680
685
  }
681
686
  /**
682
- * Reply message
687
+ * Create a durable task and send one linked visible message.
688
+ */
689
+ async trackedSend(options) {
690
+ const args = compactRecord({
691
+ group_id: options.groupId,
692
+ title: options.title,
693
+ text: options.text,
694
+ outcome: options.outcome,
695
+ assignee: options.assignee,
696
+ handoff_to: options.handoffTo,
697
+ waiting_on: options.waitingOn,
698
+ checklist: options.checklist,
699
+ notes: options.notes,
700
+ by: options.by ?? 'user',
701
+ to: options.to,
702
+ priority: options.priority ?? 'normal',
703
+ reply_required: options.replyRequired ?? true,
704
+ path: options.path,
705
+ });
706
+ return this.call('tracked_send', args);
707
+ }
708
+ /**
709
+ * Reply to a message.
710
+ * @returns The created event and optional ack event.
683
711
  */
684
712
  async reply(options) {
685
713
  const args = {
@@ -694,6 +722,60 @@ export class CCCCClient {
694
722
  args['to'] = options.to;
695
723
  return this.call('reply', args);
696
724
  }
725
+ /**
726
+ * Send a message and wait for a reply to it.
727
+ * Opens the event stream **before** sending to avoid race conditions,
728
+ * then yields control to the stream until a matching reply arrives.
729
+ *
730
+ * @param options - Send options plus `listenAs` (the actor to listen as) and optional `waitTimeoutMs`.
731
+ * @returns The reply event (a `chat.message` whose `reply_to` matches the sent event ID).
732
+ * @throws {DaemonAPIError} On send/stream errors.
733
+ * @throws {Error} If the timeout elapses or the signal is aborted before a reply arrives.
734
+ */
735
+ async sendAndWaitForReply(options) {
736
+ const waitTimeout = options.waitTimeoutMs ?? 60000;
737
+ const deadline = Date.now() + waitTimeout;
738
+ // Open stream FIRST (with sinceTs = now) to avoid missing replies
739
+ // that arrive between send() returning and stream connecting.
740
+ const stream = this.eventsStream({
741
+ groupId: options.groupId,
742
+ by: options.listenAs,
743
+ kinds: ['chat.message'],
744
+ sinceTs: new Date().toISOString(),
745
+ signal: options.signal,
746
+ });
747
+ let nextItem = stream.next();
748
+ // Now send the message
749
+ const sendResult = await this.send(options);
750
+ const sentEventId = sendResult.event.id;
751
+ try {
752
+ while (true) {
753
+ if (options.signal?.aborted) {
754
+ throw new Error('sendAndWaitForReply aborted');
755
+ }
756
+ if (Date.now() > deadline) {
757
+ throw new Error(`sendAndWaitForReply timed out after ${waitTimeout}ms`);
758
+ }
759
+ const { value: item, done } = await nextItem;
760
+ if (done)
761
+ break;
762
+ nextItem = stream.next();
763
+ if (isStreamEvent(item)) {
764
+ const evt = item.event;
765
+ if (evt.kind === 'chat.message') {
766
+ const data = evt.data;
767
+ if (data['reply_to'] === sentEventId) {
768
+ return evt;
769
+ }
770
+ }
771
+ }
772
+ }
773
+ }
774
+ finally {
775
+ await stream.return(undefined);
776
+ }
777
+ throw new Error('sendAndWaitForReply: stream ended without reply');
778
+ }
697
779
  /**
698
780
  * Acknowledge chat message
699
781
  */
@@ -705,6 +787,56 @@ export class CCCCClient {
705
787
  by: by ?? actorId,
706
788
  });
707
789
  }
790
+ /**
791
+ * Send a file as a chat attachment.
792
+ * @param options - File path (relative to scope root), optional caption and recipients.
793
+ * @returns The created event and optional ack event.
794
+ */
795
+ async fileSend(options) {
796
+ const args = {
797
+ group_id: options.groupId,
798
+ path: options.path,
799
+ by: options.by ?? 'user',
800
+ priority: options.priority ?? 'normal',
801
+ reply_required: options.replyRequired ?? false,
802
+ };
803
+ if (options.text)
804
+ args['text'] = options.text;
805
+ if (options.to)
806
+ args['to'] = options.to;
807
+ return this.call('file_send', args);
808
+ }
809
+ /**
810
+ * Get recent chat messages from the ledger.
811
+ * @param options - Group ID, limit, and max chars.
812
+ * @returns The ledger tail result.
813
+ */
814
+ async ledgerTail(options) {
815
+ const args = {
816
+ group_id: options.groupId,
817
+ by: options.by ?? 'user',
818
+ };
819
+ if (options.limit !== undefined)
820
+ args['limit'] = options.limit;
821
+ if (options.maxChars !== undefined)
822
+ args['max_chars'] = options.maxChars;
823
+ return this.call('ledger_tail', args);
824
+ }
825
+ /**
826
+ * Get recent terminal output from an actor.
827
+ * @param options - Group ID, actor ID, and line count.
828
+ * @returns The terminal tail result.
829
+ */
830
+ async terminalTail(options) {
831
+ const args = {
832
+ group_id: options.groupId,
833
+ actor_id: options.actorId,
834
+ by: options.by ?? 'user',
835
+ };
836
+ if (options.lines !== undefined)
837
+ args['lines'] = options.lines;
838
+ return this.call('terminal_tail', args);
839
+ }
708
840
  // ============================================================
709
841
  // Convenience methods: inbox
710
842
  // ============================================================
@@ -776,6 +908,173 @@ export class CCCCClient {
776
908
  dry_run: options.dryRun ?? false,
777
909
  });
778
910
  }
911
+ contextOp(groupId, op, by = 'system', dryRun = false) {
912
+ return this.contextSync({ groupId, by, dryRun, ops: [op] });
913
+ }
914
+ /** Update the shared coordination brief (Context Ops v3). */
915
+ async coordinationBriefUpdate(options) {
916
+ return this.contextOp(options.groupId, compactRecord({
917
+ op: 'coordination.brief.update',
918
+ objective: options.objective,
919
+ current_focus: options.currentFocus,
920
+ constraints: options.constraints,
921
+ project_brief: options.projectBrief,
922
+ project_brief_stale: options.projectBriefStale,
923
+ }), options.by, options.dryRun);
924
+ }
925
+ /** Add a compact coordination decision or handoff note. */
926
+ async coordinationNoteAdd(options) {
927
+ return this.contextOp(options.groupId, compactRecord({
928
+ op: 'coordination.note.add',
929
+ kind: options.kind,
930
+ summary: options.summary,
931
+ task_id: options.taskId,
932
+ }), options.by, options.dryRun);
933
+ }
934
+ /** Create a Context Ops v3 task. */
935
+ async taskCreate(options) {
936
+ return this.contextOp(options.groupId, compactRecord({
937
+ op: 'task.create',
938
+ title: options.title,
939
+ outcome: options.outcome,
940
+ status: options.status,
941
+ parent_id: options.parentId,
942
+ assignee: options.assignee,
943
+ priority: options.priority,
944
+ blocked_by: options.blockedBy,
945
+ waiting_on: options.waitingOn,
946
+ handoff_to: options.handoffTo,
947
+ task_type: options.taskType,
948
+ notes: options.notes,
949
+ checklist: options.checklist,
950
+ }), options.by, options.dryRun);
951
+ }
952
+ /** Update a Context Ops v3 task. */
953
+ async taskUpdate(options) {
954
+ return this.contextOp(options.groupId, compactRecord({
955
+ op: 'task.update',
956
+ task_id: options.taskId,
957
+ title: options.title,
958
+ outcome: options.outcome,
959
+ status: options.status,
960
+ assignee: options.assignee,
961
+ priority: options.priority,
962
+ blocked_by: options.blockedBy,
963
+ waiting_on: options.waitingOn,
964
+ handoff_to: options.handoffTo,
965
+ notes: options.notes,
966
+ checklist: options.checklist,
967
+ }), options.by, options.dryRun);
968
+ }
969
+ /** Move a task through the canonical lifecycle operation. */
970
+ async taskMove(options) {
971
+ return this.contextOp(options.groupId, {
972
+ op: 'task.move',
973
+ task_id: options.taskId,
974
+ status: options.status,
975
+ }, options.by, options.dryRun);
976
+ }
977
+ /** Restore an archived task. */
978
+ async taskRestore(options) {
979
+ return this.contextOp(options.groupId, {
980
+ op: 'task.restore',
981
+ task_id: options.taskId,
982
+ }, options.by, options.dryRun);
983
+ }
984
+ /** Update per-actor working memory. */
985
+ async agentStateUpdate(options) {
986
+ return this.contextOp(options.groupId, compactRecord({
987
+ op: 'agent_state.update',
988
+ actor_id: options.actorId,
989
+ active_task_id: options.activeTaskId,
990
+ focus: options.focus,
991
+ next_action: options.nextAction,
992
+ what_changed: options.whatChanged,
993
+ blockers: options.blockers,
994
+ open_loops: options.openLoops,
995
+ commitments: options.commitments,
996
+ environment_summary: options.environmentSummary,
997
+ user_model: options.userModel,
998
+ persona_notes: options.personaNotes,
999
+ resume_hint: options.resumeHint,
1000
+ }), options.by, options.dryRun);
1001
+ }
1002
+ /** Clear per-actor working memory. */
1003
+ async agentStateClear(options) {
1004
+ return this.contextOp(options.groupId, {
1005
+ op: 'agent_state.clear',
1006
+ actor_id: options.actorId,
1007
+ }, options.by, options.dryRun);
1008
+ }
1009
+ /** Merge restricted projection metadata. */
1010
+ async metaMerge(options) {
1011
+ return this.contextOp(options.groupId, {
1012
+ op: 'meta.merge',
1013
+ data: options.data,
1014
+ }, options.by, options.dryRun);
1015
+ }
1016
+ // ============================================================
1017
+ // Convenience methods: capability use and memory
1018
+ // ============================================================
1019
+ async capabilityUse(options) {
1020
+ const enableResult = await this.capabilityEnable(options);
1021
+ if (!options.toolName)
1022
+ return enableResult;
1023
+ return this.call('capability_tool_call', compactRecord({
1024
+ group_id: options.groupId,
1025
+ actor_id: options.actorId,
1026
+ by: options.by,
1027
+ tool_name: options.toolName,
1028
+ arguments: options.toolArguments ?? {},
1029
+ }));
1030
+ }
1031
+ async memorySearch(options) {
1032
+ return this.call('memory_search', compactRecord({
1033
+ group_id: options.groupId,
1034
+ actor_id: options.actorId,
1035
+ query: options.query,
1036
+ limit: options.limit,
1037
+ tags: options.tags,
1038
+ target: options.target,
1039
+ }));
1040
+ }
1041
+ async memoryGet(options) {
1042
+ return this.call('memory_get', compactRecord({
1043
+ group_id: options.groupId,
1044
+ actor_id: options.actorId,
1045
+ path: options.path,
1046
+ target: options.target,
1047
+ date: options.date,
1048
+ offset: options.offset,
1049
+ limit: options.limit,
1050
+ }));
1051
+ }
1052
+ async memoryWrite(options) {
1053
+ return this.call('memory_write', compactRecord({
1054
+ group_id: options.groupId,
1055
+ actor_id: options.actorId,
1056
+ target: options.target,
1057
+ content: options.content,
1058
+ tags: options.tags,
1059
+ source_refs: options.sourceRefs,
1060
+ idempotency_key: options.idempotencyKey,
1061
+ dedup_intent: options.dedupIntent,
1062
+ dedup_query: options.dedupQuery,
1063
+ }));
1064
+ }
1065
+ async memoryHealth(options = {}) {
1066
+ return this.call('memory_health', compactRecord({
1067
+ group_id: options.groupId,
1068
+ }));
1069
+ }
1070
+ async memoryProfileGet(options) {
1071
+ return this.call('memory_profile_get', compactRecord({
1072
+ group_id: options.groupId,
1073
+ actor_id: options.actorId,
1074
+ user_id: options.userId,
1075
+ tags: options.tags,
1076
+ }));
1077
+ }
779
1078
  // ============================================================
780
1079
  // Convenience methods: Group Space
781
1080
  // ============================================================
@@ -985,11 +1284,14 @@ export class CCCCClient {
985
1284
  * Subscribe to the group event stream (Server-Sent Events style, long-lived connection).
986
1285
  * Yields {@link EventStreamItem} objects as they arrive. The socket is destroyed
987
1286
  * when the generator is returned or thrown.
988
- * @param options - Group ID, event filters, and optional since cursor.
1287
+ * @param options - Group ID, event filters, optional since cursor, and optional AbortSignal.
989
1288
  * @yields {EventStreamItem} Each event or heartbeat from the stream.
990
1289
  * @throws {DaemonAPIError} If the handshake fails.
991
1290
  */
992
1291
  async *eventsStream(options) {
1292
+ // Check abort before connecting
1293
+ if (options.signal?.aborted)
1294
+ return;
993
1295
  const args = {
994
1296
  group_id: options.groupId,
995
1297
  by: options.by ?? 'user',
@@ -1015,20 +1317,37 @@ export class CCCCClient {
1015
1317
  socket.destroy();
1016
1318
  throw new DaemonAPIError(handshake.error?.code ?? 'unknown', handshake.error?.message ?? 'Handshake failed', handshake.error?.details, handshake);
1017
1319
  }
1320
+ // Wire up AbortSignal to destroy the socket
1321
+ const onAbort = () => { socket.destroy(); };
1322
+ options.signal?.addEventListener('abort', onAbort, { once: true });
1018
1323
  try {
1019
1324
  for await (const line of readLines(socket, initialBuffer)) {
1325
+ if (options.signal?.aborted)
1326
+ return;
1327
+ let parsed;
1020
1328
  try {
1021
- const parsed = JSON.parse(line);
1022
- if (parsed !== null && typeof parsed === 'object' && 't' in parsed) {
1023
- yield parsed;
1024
- }
1329
+ parsed = JSON.parse(line);
1025
1330
  }
1026
1331
  catch {
1027
- // Skip invalid JSON lines.
1332
+ // Non-JSON line from daemon; surface as an error so callers know
1333
+ // something unexpected happened instead of silently losing data.
1334
+ throw new DaemonAPIError('invalid_stream_data', `Unparseable event stream line: ${line.slice(0, 200)}`, {});
1335
+ }
1336
+ if (parsed !== null && typeof parsed === 'object') {
1337
+ const obj = parsed;
1338
+ // Daemon may send error objects in the stream (ok: false).
1339
+ if ('ok' in obj && obj['ok'] === false) {
1340
+ const err = obj['error'];
1341
+ throw new DaemonAPIError(err?.['code'] ?? 'stream_error', err?.['message'] ?? 'Daemon sent error in event stream', err?.['details'] ?? {}, obj);
1342
+ }
1343
+ if ('t' in obj) {
1344
+ yield parsed;
1345
+ }
1028
1346
  }
1029
1347
  }
1030
1348
  }
1031
1349
  finally {
1350
+ options.signal?.removeEventListener('abort', onAbort);
1032
1351
  socket.destroy();
1033
1352
  }
1034
1353
  }