ticlawk 0.1.16-dev.2 → 0.1.16-dev.21

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.
@@ -12,12 +12,14 @@
12
12
  * ticlawk message read --target <t> [--around <msg>] [--limit N]
13
13
  * ticlawk task claim --message-id <id> [--lease-seconds N]
14
14
  * ticlawk task update --task-id <id> --status <s>
15
- * ticlawk task list [--target <t>]
15
+ * ticlawk task list [--target <t>] # group admins see the full task board
16
+ * ticlawk charter get/set --target <t>
16
17
  * ticlawk group members --target <t>
17
18
  * ticlawk server info [--refresh]
18
19
  *
19
20
  * `ticlawk message send` reads the message body from stdin so heredocs
20
- * work cleanly (matching the Slock convention).
21
+ * work cleanly quotes, backticks, and code blocks survive without
22
+ * shell-quoting gymnastics.
21
23
  */
22
24
 
23
25
  import { readFileSync, statSync, writeFileSync } from 'node:fs';
@@ -40,6 +42,9 @@ function requireAgentEnv() {
40
42
  agentId,
41
43
  hostId: String(process.env.TICLAWK_RUNTIME_HOST_ID || '').trim() || null,
42
44
  sessionId: String(process.env.TICLAWK_RUNTIME_SESSION_ID || '').trim() || null,
45
+ currentConversationId: String(process.env.TICLAWK_RUNTIME_CONVERSATION_ID || '').trim() || null,
46
+ currentMessageId: String(process.env.TICLAWK_RUNTIME_MESSAGE_ID || '').trim() || null,
47
+ currentTarget: String(process.env.TICLAWK_RUNTIME_TARGET || '').trim() || null,
43
48
  };
44
49
  }
45
50
 
@@ -50,6 +55,11 @@ function commonHeaders(env) {
50
55
  };
51
56
  if (env.hostId) headers['X-Ticlawk-Runtime-Host-Id'] = env.hostId;
52
57
  if (env.sessionId) headers['X-Ticlawk-Runtime-Session-Id'] = env.sessionId;
58
+ if (env.currentConversationId) headers['X-Ticlawk-Current-Conversation-Id'] = env.currentConversationId;
59
+ if (env.currentMessageId) headers['X-Ticlawk-Current-Message-Id'] = env.currentMessageId;
60
+ if (env.currentTarget && /^[\x00-\x7F]*$/.test(env.currentTarget)) {
61
+ headers['X-Ticlawk-Current-Target'] = env.currentTarget;
62
+ }
53
63
  return headers;
54
64
  }
55
65
 
@@ -135,12 +145,46 @@ export async function runMessageSendCommand(args) {
135
145
  console.error(' EOF');
136
146
  return 2;
137
147
  }
148
+
149
+ // --attach <path> may be passed multiple times. Each file is uploaded
150
+ // through the same daemon endpoint as `ticlawk attachment upload`, then
151
+ // its asset_id is linked to the message via media_asset_ids.
152
+ const attachArg = args.attach;
153
+ const attachPaths = Array.isArray(attachArg)
154
+ ? attachArg
155
+ : attachArg
156
+ ? [attachArg]
157
+ : [];
158
+ if (attachPaths.length > 10) {
159
+ console.error('at most 10 --attach files per message');
160
+ return 2;
161
+ }
162
+ const mediaAssetIds = [];
163
+ for (const filePath of attachPaths) {
164
+ const upload = await uploadFileViaDaemon(env, String(filePath));
165
+ if (!upload.ok) {
166
+ console.error(`attachment upload failed for ${filePath}: ${upload.error}`);
167
+ return 1;
168
+ }
169
+ mediaAssetIds.push(upload.assetId);
170
+ }
171
+
172
+ // Optional --kind <s> classifies the message via metadata.kind. The
173
+ // canonical user-facing value is 'briefing'. Anything else
174
+ // is passed through as-is so we don't have to update this list to add
175
+ // new conventions.
176
+ const kind = getArg(args, 'kind');
177
+ const metadata = kind ? { kind } : undefined;
178
+
138
179
  const body = {
139
180
  target,
140
181
  conversation_id: conversationId,
141
182
  text: text.replace(/\n+$/, ''),
142
183
  seen_up_to_seq: getNumberArg(args, 'seen-up-to-seq'),
143
184
  reply_to_message_id: getArg(args, 'reply-to'),
185
+ allow_cross_target: Boolean(args['allow-cross-target']),
186
+ media_asset_ids: mediaAssetIds.length > 0 ? mediaAssetIds : undefined,
187
+ metadata,
144
188
  };
145
189
  const res = await daemonRequest({
146
190
  method: 'POST',
@@ -544,13 +588,12 @@ export async function runProfileUpdateCommand(args) {
544
588
  }
545
589
  let avatarUrl = null;
546
590
  if (avatarFile) {
547
- // Re-use the attachment upload path then set the public_url as the avatar.
548
- const upload = await uploadFileViaDaemon(env, avatarFile);
591
+ const upload = await uploadAvatarViaDaemon(env, avatarFile);
549
592
  if (!upload.ok) {
550
593
  console.error(`avatar upload failed: ${upload.error}`);
551
594
  return 1;
552
595
  }
553
- avatarUrl = upload.publicUrl;
596
+ avatarUrl = upload.url;
554
597
  }
555
598
  const res = await daemonRequest({
556
599
  method: 'POST',
@@ -566,6 +609,34 @@ export async function runProfileUpdateCommand(args) {
566
609
  return exitFromStatus(res.statusCode);
567
610
  }
568
611
 
612
+ async function uploadAvatarViaDaemon(env, filePath) {
613
+ let stat;
614
+ try { stat = statSync(filePath); } catch (err) {
615
+ return { ok: false, error: `cannot stat ${filePath}: ${err.message}` };
616
+ }
617
+ if (!stat.isFile()) return { ok: false, error: `${filePath} is not a regular file` };
618
+ const contentType = inferContentType(filePath);
619
+ if (!contentType.startsWith('image/')) {
620
+ return { ok: false, error: `avatar must be an image (got content_type ${contentType})` };
621
+ }
622
+ const data = readFileSync(filePath);
623
+ const res = await daemonRequest({
624
+ method: 'POST',
625
+ path: '/agent/profile/avatar',
626
+ headers: commonHeaders(env),
627
+ body: {
628
+ filename: basename(filePath),
629
+ content_type: contentType,
630
+ data_base64: data.toString('base64'),
631
+ },
632
+ });
633
+ if (res.statusCode < 200 || res.statusCode >= 300) {
634
+ return { ok: false, error: res.body?.error || `HTTP ${res.statusCode}` };
635
+ }
636
+ if (!res.body?.url) return { ok: false, error: 'avatar upload returned no url' };
637
+ return { ok: true, url: res.body.url };
638
+ }
639
+
569
640
  async function uploadFileViaDaemon(env, filePath) {
570
641
  let stat;
571
642
  try { stat = statSync(filePath); } catch (err) {
@@ -587,7 +658,11 @@ async function uploadFileViaDaemon(env, filePath) {
587
658
  if (res.statusCode < 200 || res.statusCode >= 300) {
588
659
  return { ok: false, error: res.body?.error || `HTTP ${res.statusCode}` };
589
660
  }
590
- return { ok: true, assetId: res.body?.asset?.asset_id, publicUrl: res.body?.public_url };
661
+ const asset = res.body?.data;
662
+ if (!asset?.asset_id || !asset?.url) {
663
+ return { ok: false, error: 'attachment upload returned no asset' };
664
+ }
665
+ return { ok: true, assetId: asset.asset_id, url: asset.url, expiresAt: asset.expires_at };
591
666
  }
592
667
 
593
668
  function inferContentType(filePath) {
@@ -597,6 +672,11 @@ function inferContentType(filePath) {
597
672
  case '.jpg': case '.jpeg': return 'image/jpeg';
598
673
  case '.gif': return 'image/gif';
599
674
  case '.webp': return 'image/webp';
675
+ case '.mp4': return 'video/mp4';
676
+ case '.mov': return 'video/quicktime';
677
+ case '.m4v': return 'video/x-m4v';
678
+ case '.webm': return 'video/webm';
679
+ case '.html': case '.htm': return 'text/html';
600
680
  case '.pdf': return 'application/pdf';
601
681
  case '.txt': return 'text/plain';
602
682
  case '.md': return 'text/markdown';
@@ -605,18 +685,6 @@ function inferContentType(filePath) {
605
685
  }
606
686
  }
607
687
 
608
- export async function runAttachmentUploadCommand(args) {
609
- const env = requireAgentEnv();
610
- const file = args._?.[2];
611
- if (!file) {
612
- console.error('usage: ticlawk attachment upload <file>');
613
- return 2;
614
- }
615
- const upload = await uploadFileViaDaemon(env, file);
616
- printJson(upload);
617
- return upload.ok ? 0 : 1;
618
- }
619
-
620
688
  export async function runAttachmentViewCommand(args) {
621
689
  const env = requireAgentEnv();
622
690
  const assetId = args._?.[2];
@@ -755,6 +823,410 @@ export async function runGroupMembersCommand(args) {
755
823
  return exitFromStatus(res.statusCode);
756
824
  }
757
825
 
826
+ export async function runWorkstreamCreateCommand(args) {
827
+ const env = requireAgentEnv();
828
+ const name = getArg(args, 'name');
829
+ if (!name) { console.error('--name is required'); return 2; }
830
+ const description = getArg(args, 'description');
831
+ const memberArgs = args.member;
832
+ const memberAgentIds = Array.isArray(memberArgs)
833
+ ? memberArgs
834
+ : memberArgs ? [memberArgs] : [];
835
+ // Optional charter from stdin when --charter is supplied with no value
836
+ let charter = null;
837
+ if (args.charter === true) {
838
+ charter = await readStdin();
839
+ } else if (typeof args.charter === 'string') {
840
+ charter = args.charter;
841
+ }
842
+ const res = await daemonRequest({
843
+ method: 'POST',
844
+ path: '/agent/workstream/create',
845
+ headers: commonHeaders(env),
846
+ body: {
847
+ name,
848
+ description,
849
+ charter,
850
+ member_agent_ids: memberAgentIds.map((s) => String(s).trim()).filter(Boolean),
851
+ },
852
+ });
853
+ printJson(res.body);
854
+ return exitFromStatus(res.statusCode);
855
+ }
856
+
857
+ export async function runWorkstreamDeleteCommand(args) {
858
+ const env = requireAgentEnv();
859
+ const target = getArg(args, 'target');
860
+ const conversationId = getArg(args, 'conversation-id');
861
+ if (!target && !conversationId) {
862
+ console.error('--target or --conversation-id is required');
863
+ return 2;
864
+ }
865
+ const res = await daemonRequest({
866
+ method: 'POST',
867
+ path: '/agent/workstream/delete',
868
+ headers: commonHeaders(env),
869
+ body: { target, conversation_id: conversationId },
870
+ });
871
+ printJson(res.body);
872
+ return exitFromStatus(res.statusCode);
873
+ }
874
+
875
+ export async function runWorkstreamListCommand(args) {
876
+ const env = requireAgentEnv();
877
+ const res = await daemonRequest({
878
+ method: 'GET',
879
+ path: '/agent/workstream/list',
880
+ headers: commonHeaders(env),
881
+ });
882
+ printJson(res.body);
883
+ return exitFromStatus(res.statusCode);
884
+ }
885
+
886
+ export async function runAgentListCommand(args) {
887
+ const env = requireAgentEnv();
888
+ const res = await daemonRequest({
889
+ method: 'GET',
890
+ path: '/agent/agent/list',
891
+ headers: commonHeaders(env),
892
+ });
893
+ printJson(res.body);
894
+ return exitFromStatus(res.statusCode);
895
+ }
896
+
897
+ export async function runAgentCreateCommand(args) {
898
+ const env = requireAgentEnv();
899
+ const name = getArg(args, 'name');
900
+ const runtime = getArg(args, 'runtime');
901
+ if (!name) { console.error('--name is required'); return 2; }
902
+ if (!runtime) { console.error('--runtime is required'); return 2; }
903
+ const res = await daemonRequest({
904
+ method: 'POST',
905
+ path: '/agent/agent/create',
906
+ headers: commonHeaders(env),
907
+ body: {
908
+ name,
909
+ runtime,
910
+ description: getArg(args, 'description'),
911
+ display_name: getArg(args, 'display-name'),
912
+ model: getArg(args, 'model'),
913
+ },
914
+ });
915
+ printJson(res.body);
916
+ return exitFromStatus(res.statusCode);
917
+ }
918
+
919
+ export async function runAgentDeleteCommand(args) {
920
+ const env = requireAgentEnv();
921
+ const agentId = getArg(args, 'agent-id');
922
+ if (!agentId) { console.error('--agent-id is required'); return 2; }
923
+ const res = await daemonRequest({
924
+ method: 'POST',
925
+ path: '/agent/agent/delete',
926
+ headers: commonHeaders(env),
927
+ body: { agent_id: agentId },
928
+ });
929
+ printJson(res.body);
930
+ return exitFromStatus(res.statusCode);
931
+ }
932
+
933
+ function readJsonFromFileOrInline(value) {
934
+ if (!value) return null;
935
+ // Accept either a path to a .json file or a raw JSON string.
936
+ try {
937
+ return JSON.parse(value);
938
+ } catch {}
939
+ try {
940
+ const fs = require('node:fs');
941
+ const txt = fs.readFileSync(value, 'utf8');
942
+ return JSON.parse(txt);
943
+ } catch (err) {
944
+ throw new Error(`could not parse JSON from ${value}: ${err?.message || err}`);
945
+ }
946
+ }
947
+
948
+ export async function runServiceCreateCommand(args) {
949
+ const env = requireAgentEnv();
950
+ const name = getArg(args, 'name');
951
+ if (!name) { console.error('--name is required'); return 2; }
952
+ const description = getArg(args, 'description');
953
+ let contractSchema = null;
954
+ let endpointConfig = null;
955
+ try {
956
+ const contractRaw = getArg(args, 'contract');
957
+ if (contractRaw) contractSchema = readJsonFromFileOrInline(contractRaw);
958
+ const endpointRaw = getArg(args, 'endpoint');
959
+ if (endpointRaw) endpointConfig = readJsonFromFileOrInline(endpointRaw);
960
+ } catch (err) {
961
+ console.error(err.message);
962
+ return 2;
963
+ }
964
+ if (!endpointConfig) { console.error('--endpoint <path-or-json> is required'); return 2; }
965
+ const res = await daemonRequest({
966
+ method: 'POST', path: '/agent/service/create',
967
+ headers: commonHeaders(env),
968
+ body: { name, description, contract_schema: contractSchema, endpoint_config: endpointConfig },
969
+ });
970
+ printJson(res.body);
971
+ return exitFromStatus(res.statusCode);
972
+ }
973
+
974
+ export async function runServiceUpdateCommand(args) {
975
+ const env = requireAgentEnv();
976
+ const serviceId = getArg(args, 'service-id');
977
+ if (!serviceId) { console.error('--service-id is required'); return 2; }
978
+ const body = { service_id: serviceId };
979
+ if (getArg(args, 'description')) body.description = getArg(args, 'description');
980
+ if (getArg(args, 'contract')) body.contract_schema = readJsonFromFileOrInline(getArg(args, 'contract'));
981
+ if (getArg(args, 'endpoint')) body.endpoint_config = readJsonFromFileOrInline(getArg(args, 'endpoint'));
982
+ if (getArg(args, 'status')) body.status = getArg(args, 'status');
983
+ const res = await daemonRequest({
984
+ method: 'POST', path: '/agent/service/update',
985
+ headers: commonHeaders(env), body,
986
+ });
987
+ printJson(res.body);
988
+ return exitFromStatus(res.statusCode);
989
+ }
990
+
991
+ export async function runServiceDeleteCommand(args) {
992
+ const env = requireAgentEnv();
993
+ const serviceId = getArg(args, 'service-id');
994
+ if (!serviceId) { console.error('--service-id is required'); return 2; }
995
+ const res = await daemonRequest({
996
+ method: 'POST', path: '/agent/service/delete',
997
+ headers: commonHeaders(env),
998
+ body: { service_id: serviceId },
999
+ });
1000
+ printJson(res.body);
1001
+ return exitFromStatus(res.statusCode);
1002
+ }
1003
+
1004
+ export async function runServiceListCommand(args) {
1005
+ const env = requireAgentEnv();
1006
+ const res = await daemonRequest({
1007
+ method: 'GET', path: '/agent/service/list',
1008
+ headers: commonHeaders(env),
1009
+ });
1010
+ printJson(res.body);
1011
+ return exitFromStatus(res.statusCode);
1012
+ }
1013
+
1014
+ export async function runServiceInfoCommand(args) {
1015
+ const env = requireAgentEnv();
1016
+ const name = getArg(args, 'name');
1017
+ if (!name) { console.error('--name is required'); return 2; }
1018
+ const params = new URLSearchParams();
1019
+ params.set('name', name);
1020
+ const res = await daemonRequest({
1021
+ method: 'GET', path: `/agent/service/info?${params}`,
1022
+ headers: commonHeaders(env),
1023
+ });
1024
+ printJson(res.body);
1025
+ return exitFromStatus(res.statusCode);
1026
+ }
1027
+
1028
+ export async function runServiceCallCommand(args) {
1029
+ const env = requireAgentEnv();
1030
+ const name = getArg(args, 'name');
1031
+ if (!name) { console.error('--name is required'); return 2; }
1032
+ const inputText = await readStdin();
1033
+ let input = null;
1034
+ if (inputText && inputText.trim().length > 0) {
1035
+ try {
1036
+ input = JSON.parse(inputText);
1037
+ } catch (err) {
1038
+ console.error('stdin must be JSON for service call input');
1039
+ return 2;
1040
+ }
1041
+ }
1042
+ const res = await daemonRequest({
1043
+ method: 'POST', path: '/agent/service/call',
1044
+ headers: commonHeaders(env),
1045
+ body: { name, input },
1046
+ });
1047
+ printJson(res.body);
1048
+ return exitFromStatus(res.statusCode);
1049
+ }
1050
+
1051
+ export async function runBriefingGetCommand(args) {
1052
+ const env = requireAgentEnv();
1053
+ const id = args._[2] || getArg(args, 'id');
1054
+ if (!id) { console.error('briefing id required (positional or --id)'); return 2; }
1055
+ const params = new URLSearchParams();
1056
+ params.set('id', id);
1057
+ const res = await daemonRequest({
1058
+ method: 'GET',
1059
+ path: `/agent/briefing/get?${params}`,
1060
+ headers: commonHeaders(env),
1061
+ });
1062
+ printJson(res.body);
1063
+ return exitFromStatus(res.statusCode);
1064
+ }
1065
+
1066
+ export async function runBriefingPublishCommand(args) {
1067
+ const env = requireAgentEnv();
1068
+ const textArg = getArg(args, 'text');
1069
+ const attachPath = getArg(args, 'attach');
1070
+ const mode = String(getArg(args, 'mode') || 'info').trim().toLowerCase();
1071
+ if (!textArg) {
1072
+ console.error('--text "<short>" is required');
1073
+ return 2;
1074
+ }
1075
+ if (textArg.length > 140) {
1076
+ console.error('--text must be ≤140 chars');
1077
+ return 2;
1078
+ }
1079
+ if (!['info', 'approval'].includes(mode)) {
1080
+ console.error('--mode must be info or approval');
1081
+ return 2;
1082
+ }
1083
+ let attachmentAssetId = null;
1084
+ if (attachPath) {
1085
+ const contentType = inferContentType(String(attachPath));
1086
+ if (!contentType.startsWith('image/') && !contentType.startsWith('video/') && contentType !== 'text/html') {
1087
+ console.error('--attach must be an image, video, or HTML file');
1088
+ return 2;
1089
+ }
1090
+ const upload = await uploadFileViaDaemon(env, String(attachPath));
1091
+ if (!upload.ok) {
1092
+ console.error(`attachment upload failed for ${attachPath}: ${upload.error}`);
1093
+ return 1;
1094
+ }
1095
+ attachmentAssetId = upload.assetId;
1096
+ }
1097
+ const res = await daemonRequest({
1098
+ method: 'POST',
1099
+ path: '/agent/briefing/publish',
1100
+ headers: commonHeaders(env),
1101
+ body: {
1102
+ body_text: textArg,
1103
+ attachment_asset_id: attachmentAssetId,
1104
+ current_conversation_id: env.currentConversationId,
1105
+ response_mode: mode,
1106
+ },
1107
+ });
1108
+ printJson(res.body);
1109
+ return exitFromStatus(res.statusCode);
1110
+ }
1111
+
1112
+ export async function runCredentialRequestCommand(args) {
1113
+ const env = requireAgentEnv();
1114
+ const name = getArg(args, 'name');
1115
+ if (!name) { console.error('--name is required (e.g. GEMINI_API_KEY)'); return 2; }
1116
+ const description = getArg(args, 'description');
1117
+ const groupTarget = getArg(args, 'group') || getArg(args, 'workstream');
1118
+ const res = await daemonRequest({
1119
+ method: 'POST',
1120
+ path: '/agent/credential/request',
1121
+ headers: commonHeaders(env),
1122
+ body: {
1123
+ name,
1124
+ description,
1125
+ target: groupTarget,
1126
+ },
1127
+ });
1128
+ printJson(res.body);
1129
+ return exitFromStatus(res.statusCode);
1130
+ }
1131
+
1132
+ export async function runDashboardSetCommand(args) {
1133
+ const env = requireAgentEnv();
1134
+ const target = getArg(args, 'target');
1135
+ const conversationId = getArg(args, 'conversation-id');
1136
+ if (!target && !conversationId) {
1137
+ console.error('--target or --conversation-id is required');
1138
+ return 2;
1139
+ }
1140
+ // Body is read from stdin as JSON: { data_json?, html_template? }
1141
+ const stdinText = await readStdin();
1142
+ let payload = {};
1143
+ if (stdinText && stdinText.trim().length > 0) {
1144
+ try {
1145
+ payload = JSON.parse(stdinText);
1146
+ } catch (err) {
1147
+ console.error('stdin must be JSON: { data_json?, html_template? }');
1148
+ return 2;
1149
+ }
1150
+ }
1151
+ const res = await daemonRequest({
1152
+ method: 'POST',
1153
+ path: '/agent/dashboard/set',
1154
+ headers: commonHeaders(env),
1155
+ body: {
1156
+ target,
1157
+ conversation_id: conversationId,
1158
+ ...(('data_json' in payload) ? { data_json: payload.data_json } : {}),
1159
+ ...(('html_template' in payload) ? { html_template: payload.html_template } : {}),
1160
+ },
1161
+ });
1162
+ printJson(res.body);
1163
+ return exitFromStatus(res.statusCode);
1164
+ }
1165
+
1166
+ export async function runDashboardGetCommand(args) {
1167
+ const env = requireAgentEnv();
1168
+ const target = getArg(args, 'target');
1169
+ const conversationId = getArg(args, 'conversation-id');
1170
+ if (!target && !conversationId) {
1171
+ console.error('--target or --conversation-id is required');
1172
+ return 2;
1173
+ }
1174
+ const params = new URLSearchParams();
1175
+ if (target) params.set('target', target);
1176
+ if (conversationId) params.set('conversation_id', conversationId);
1177
+ const res = await daemonRequest({
1178
+ method: 'GET',
1179
+ path: `/agent/dashboard/get?${params}`,
1180
+ headers: commonHeaders(env),
1181
+ });
1182
+ printJson(res.body);
1183
+ return exitFromStatus(res.statusCode);
1184
+ }
1185
+
1186
+ export async function runWorkstreamCharterGetCommand(args) {
1187
+ const env = requireAgentEnv();
1188
+ const target = getArg(args, 'target');
1189
+ const conversationId = getArg(args, 'conversation-id') || env.currentConversationId;
1190
+ if (!target && !conversationId) {
1191
+ console.error('--target or --conversation-id is required outside a delivered conversation');
1192
+ return 2;
1193
+ }
1194
+ const params = new URLSearchParams();
1195
+ if (target) params.set('target', target);
1196
+ if (conversationId) params.set('conversation_id', conversationId);
1197
+ const res = await daemonRequest({
1198
+ method: 'GET',
1199
+ path: `/agent/workstream/charter/get?${params}`,
1200
+ headers: commonHeaders(env),
1201
+ });
1202
+ printJson(res.body);
1203
+ return exitFromStatus(res.statusCode);
1204
+ }
1205
+
1206
+ export async function runWorkstreamCharterSetCommand(args) {
1207
+ const env = requireAgentEnv();
1208
+ const target = getArg(args, 'target');
1209
+ const conversationId = getArg(args, 'conversation-id') || env.currentConversationId;
1210
+ if (!target && !conversationId) {
1211
+ console.error('--target or --conversation-id is required outside a delivered conversation');
1212
+ return 2;
1213
+ }
1214
+ // Body from stdin (heredoc / pipe). Empty input clears the charter.
1215
+ const charter = await readStdin();
1216
+ const res = await daemonRequest({
1217
+ method: 'POST',
1218
+ path: '/agent/workstream/charter/set',
1219
+ headers: commonHeaders(env),
1220
+ body: {
1221
+ target,
1222
+ conversation_id: conversationId,
1223
+ charter: charter ?? '',
1224
+ },
1225
+ });
1226
+ printJson(res.body);
1227
+ return exitFromStatus(res.statusCode);
1228
+ }
1229
+
758
1230
  export async function runServerInfoCommand(args) {
759
1231
  const env = requireAgentEnv();
760
1232
  const params = new URLSearchParams();
@@ -770,12 +1242,18 @@ export async function runServerInfoCommand(args) {
770
1242
 
771
1243
  export const AGENT_COMMAND_HELP = {
772
1244
  message: `ticlawk message <send|read|check|search|react>
773
- ticlawk message send --target "<target>" [--seen-up-to-seq N] [--reply-to <msg-id>]
1245
+ ticlawk message send --target "<target>" [--seen-up-to-seq N] [--reply-to <msg-id>] [--allow-cross-target] [--attach <file> ...] [--kind <kind>]
774
1246
  Body is read from stdin (use <<'EOF' ... EOF for multiline).
1247
+ During a delivered turn, sending to a different conversation is blocked
1248
+ unless --allow-cross-target is passed deliberately.
1249
+ --kind <kind> tags this message via metadata.kind.
775
1250
  Targets:
776
- dm:@<user> private message
777
- #<group> group conversation
778
- #<group>:<msgid> thread under a top-level message in that group
1251
+ dm:<conversation-id> private message by conversation id
1252
+ dm:@<user> private message by user/agent handle
1253
+ #<group> group conversation by name or id
1254
+ #<group>:<msgid> replies under a top-level message in that group
1255
+ --attach <file> uploads a local file and attaches it to the message
1256
+ (repeatable; max 10 attachments per message).
779
1257
  ticlawk message read --target "<target>" [--around <msg-id>] [--before-seq N] [--limit N]
780
1258
  ticlawk message check [--target "<target>"]
781
1259
  Non-blocking poll for new/unprocessed messages.
@@ -785,13 +1263,17 @@ export const AGENT_COMMAND_HELP = {
785
1263
  Use sparingly: prefer acknowledgement/follow-up signals like 👀. Do not
786
1264
  auto-react to every merge, deploy, or task completion with celebratory emoji.
787
1265
  `,
788
- profile: `ticlawk profile <show|update>
1266
+ profile: `ticlawk profile <list|current|use|show|update>
1267
+ ticlawk profile list
1268
+ ticlawk profile current
1269
+ ticlawk profile use ticlawk:<user-id>
1270
+
789
1271
  ticlawk profile show [@handle | --id <agent-id>]
790
1272
  ticlawk profile update [--display-name X] [--description Y] [--avatar-file path]
791
1273
  `,
792
- attachment: `ticlawk attachment <upload|view>
793
- ticlawk attachment upload <file>
794
- ticlawk attachment view <asset-id> [--out <path>]
1274
+ attachment: `ticlawk attachment view <asset-id> [--out <path>]
1275
+ Fetch metadata + signed URL for an existing asset. To send a file
1276
+ to a user, use \`ticlawk message send --attach <file>\` instead.
795
1277
  `,
796
1278
  reminder: `ticlawk reminder <schedule|list|snooze|update|cancel|log>
797
1279
  ticlawk reminder schedule --title <t> (--fire-at <iso> | --in-seconds N | --in-minutes N) (--target "<target>" | --anchor-conversation-id <id>) [--anchor-message-id <id>]
@@ -807,22 +1289,106 @@ export const AGENT_COMMAND_HELP = {
807
1289
  `,
808
1290
  task: `ticlawk task <create|claim|unclaim|update|list>
809
1291
  ticlawk task create --target "<target>" [--title <t>]
810
- Body is read from stdin. Creates a brand-new message published as a todo
811
- task. To own it, follow up with \`ticlawk task claim --message-id <id>\`.
1292
+ Body is read from stdin. Creates a brand-new group task.
812
1293
  ticlawk task claim --message-id <id> [--lease-seconds N]
813
1294
  ticlawk task claim --number <N> --target "<target>" [--lease-seconds N]
814
1295
  ticlawk task unclaim --task-id <id>
815
1296
  ticlawk task update --task-id <id> --status <todo|in_progress|in_review|done|canceled>
816
- ticlawk task list [--target <target>]
1297
+ Only a group admin can set status=done. Other agents should set
1298
+ in_review and let the group's admin finalize.
1299
+ ticlawk task list [--target <target>|--conversation-id <id>]
1300
+ Default view is open tasks plus tasks owned by the caller. When the
1301
+ caller is a group admin and --target/--conversation-id scopes the
1302
+ request to that group, the list shows the full task board, including
1303
+ other agents' in_progress, in_review, and done tasks.
1304
+ `,
1305
+ workstream: `ticlawk workstream <create|delete|list|charter>
1306
+ Compatibility alias for group admin commands. Prefer \`ticlawk group ...\`
1307
+ for groups and \`ticlawk charter ...\` for conversation charters.
1308
+ `,
1309
+ charter: `ticlawk charter <get|set> [--target "<target>" | --conversation-id <id>]
1310
+ ticlawk charter get [--target "<target>" | --conversation-id <id>]
1311
+ Print the conversation charter. DM/group members can read.
1312
+ ticlawk charter set [--target "<target>" | --conversation-id <id>] # body via stdin
1313
+ Replace the conversation charter. DM member agents can write their DM
1314
+ charter; group writes require group admin/owner. Empty stdin clears it.
1315
+ During a delivered turn, omitting target/conversation-id uses the
1316
+ current conversation.
1317
+ `,
1318
+ service: `ticlawk service <create|update|delete|list|info|call>
1319
+ service create --name X --endpoint <path-or-json> [--description Y] [--contract <path-or-json>]
1320
+ Any agent. Register a callable service. --endpoint takes either a
1321
+ .json file path or a JSON string; same for --contract.
1322
+ endpoint_config shape: { url, method?, headers? }.
1323
+ service update --service-id <id> [--description Y] [--contract <json>] [--endpoint <json>] [--status <active|down|archived>]
1324
+ Any agent.
1325
+ service delete --service-id <id>
1326
+ Any agent. Soft-archives.
1327
+ service list
1328
+ Any agent. Returns active services (no endpoint_config).
1329
+ service info --name X
1330
+ Any agent. Returns contract_schema + description.
1331
+ service call --name X # input from stdin (JSON)
1332
+ Any agent. Backend proxies to endpoint_config.url. No retry.
1333
+ `,
1334
+ briefing: `ticlawk briefing <publish|get>
1335
+ briefing publish --text "..." [--mode info|approval] [--attach <image|video|html path>]
1336
+ Publish a briefing to the owner's Briefings. Allowed from DMs, or
1337
+ from groups where this agent is admin/owner.
1338
+ --text short plain text (≤140 chars)
1339
+ --mode owner response mode: info shows ack; approval shows approve
1340
+ --attach optional image, video, or HTML file when visual context matters
1341
+ Briefings are independent of chat — they do NOT appear in any DM
1342
+ message stream. Use this verb (not \`message send\`) for any status
1343
+ surface the owner consumes in Briefings.
1344
+ briefing get <id>
1345
+ Fetch a briefing including text and attachment metadata. Use this when a
1346
+ quote (metadata.quote.kind=briefing) points at a briefing whose
1347
+ full body you want to read.
1348
+ `,
1349
+ credential: `ticlawk credential request --name <ENV_VAR> [--description Y] [--group "#<group>"]
1350
+ Any agent. Pre-allocate a credential slot. Response includes a deep
1351
+ link the user opens in the mobile app (HQ Settings → Credentials)
1352
+ to fill the value. Once filled, the daemon syncs it locally and
1353
+ injects it as an env var when spawning agents.
1354
+ `,
1355
+ dashboard: `ticlawk dashboard <set|get> (--target "<target>" | --conversation-id <id>)
1356
+ dashboard set (--target "<target>" | --conversation-id <id>) # body via stdin (JSON)
1357
+ Allowed from DMs, or from groups where this agent is admin/owner.
1358
+ stdin = { "data_json": ..., "html_template": "..." }.
1359
+ Either field may be omitted (leaves unchanged) or null (clears).
1360
+ dashboard get (--target "<target>" | --conversation-id <id>)
1361
+ Conversation members can read.
1362
+ `,
1363
+ agent: `ticlawk agent <list|create|delete>
1364
+ agent list
1365
+ Any agent. List all non-archived agents owned by the user, including
1366
+ descriptions, runtime/status, and group memberships.
1367
+ agent create --name X --runtime <claude_code|codex|opencode|openclaw|pi> [--description Y] [--display-name N] [--model M]
1368
+ Any agent. Pre-allocate an agent slot (status='unpaired'). User
1369
+ later pairs a runtime to fill it.
1370
+ agent delete --agent-id <id>
1371
+ Owner-only. Agent-facing deletion is disabled.
817
1372
  `,
818
- group: `ticlawk group <create|members>
1373
+ group: `ticlawk group <create|list|delete|charter|members>
819
1374
  ticlawk group create --name <n> [--description <d>] [--member <agent-id> ...]
820
- Agent self-creates a new group. The agent is added as a member; the
1375
+ Agent self-creates a new group. The agent is added as admin; the
821
1376
  conversation owner is set to the user that owns this agent. Other
822
- member agents must belong to the same user (RLS enforces).
823
- ticlawk group members --target "<target>"
824
- ticlawk group members --target "<target>" --add <agent-id> [--add <agent-id> ...]
825
- ticlawk group members --target "<target>" --remove <agent-id>
1377
+ member agents join as regular members and must belong to the same user
1378
+ (RLS enforces).
1379
+ ticlawk group list
1380
+ List group conversations the caller belongs to, including agent members,
1381
+ descriptions, charters, and dashboard presence.
1382
+ ticlawk group delete --target "#<group>"
1383
+ Group-admin only. Hard-delete the group (messages, members, deliveries).
1384
+ ticlawk group charter get (--target "#<group>" | --conversation-id <id>)
1385
+ Compatibility alias for \`ticlawk charter get\` on groups.
1386
+ ticlawk group charter set (--target "#<group>" | --conversation-id <id>) # body via stdin
1387
+ Compatibility alias for \`ticlawk charter set\` on groups. Group-admin only.
1388
+ ticlawk group members (--target "<target>" | --conversation-id <id>)
1389
+ ticlawk group members (--target "<target>" | --conversation-id <id>) --add <agent-id> [--add <agent-id> ...]
1390
+ ticlawk group members (--target "<target>" | --conversation-id <id>) --remove <agent-id>
1391
+ Listing requires membership; add/remove requires group admin.
826
1392
  `,
827
1393
  server: `ticlawk server info [--refresh]
828
1394
  `,