ticlawk 0.1.16-dev.4 → 0.1.16-dev.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticlawk",
3
- "version": "0.1.16-dev.4",
3
+ "version": "0.1.16-dev.6",
4
4
  "description": "Local connector that links agent harnesses (Claude Code, Codex, OpenClaw, opencode, Pi) to the Ticlawk mobile app.",
5
5
  "type": "module",
6
6
  "main": "ticlawk.mjs",
@@ -27,6 +27,32 @@ function connectError(statusCode, error) {
27
27
  return { statusCode, body: { ok: false, error } };
28
28
  }
29
29
 
30
+ // Coalesce: returns a wrapped fn that runs at most one invocation at a
31
+ // time. If it's called while an invocation is in flight, the most recent
32
+ // args are stashed and the wrapped fn re-runs exactly once after the
33
+ // current run completes (regardless of how many times it was called
34
+ // during the run). The wrapped fn always returns the in-flight Promise.
35
+ function coalesce(fn) {
36
+ let running = null;
37
+ let pendingArgs = null;
38
+ return function call(...args) {
39
+ if (running) {
40
+ pendingArgs = args;
41
+ return running;
42
+ }
43
+ running = (async () => {
44
+ let currentArgs = args;
45
+ while (true) {
46
+ pendingArgs = null;
47
+ await fn(...currentArgs);
48
+ if (pendingArgs === null) return;
49
+ currentArgs = pendingArgs;
50
+ }
51
+ })().finally(() => { running = null; });
52
+ return running;
53
+ };
54
+ }
55
+
30
56
  function normalizeInboundMediaAssets(msg) {
31
57
  if (!Array.isArray(msg?.media_assets)) return [];
32
58
  return msg.media_assets
@@ -119,7 +145,7 @@ function buildGroupContextBlock(msg) {
119
145
  // runtime LLM never has to remember the standing prompt to figure out
120
146
  // HOW to reply. Codex in particular treats the developerInstructions as
121
147
  // background and ignores the chat-send pattern without this per-turn
122
- // nudge (Slock does the same — see chunk-M4A5QPUN.js dynamicReplyInstruction).
148
+ // nudge.
123
149
  function buildWakePromptText({ envelopeHeader, target, rawText, groupContext }) {
124
150
  const body = `${envelopeHeader} ${rawText || ''}`.trim();
125
151
  const lines = ['New message received:', '', body];
@@ -149,8 +175,8 @@ export function normalizeInboundMessage(msg) {
149
175
  const baseHeader = buildEnvelopeHeader(enriched);
150
176
  // Task + reactions suffixes are appended INSIDE the envelope so an
151
177
  // agent can see at a glance whether a message is already claimed and
152
- // who has already acknowledged it same shape as slock's incoming
153
- // message render (`[task #N status=… assignee=…]` + `[reactions: …]`).
178
+ // who has already acknowledged it: `[task #N status=… assignee=…]` +
179
+ // `[reactions: …]`.
154
180
  const taskSuffix = buildTaskSuffix(enriched);
155
181
  const reactionsSuffix = buildReactionsSuffix(enriched);
156
182
  const header = baseHeader + taskSuffix + reactionsSuffix;
@@ -257,51 +283,46 @@ function normalizeRuntimeVersion(value) {
257
283
  return Number.isInteger(value) ? Number(value) : 0;
258
284
  }
259
285
 
260
- // Build a binding from the agent table snapshot (returned by
261
- // /api/agents and used by the startup hydrate path). Reads agent.id,
262
- // agent.service_type, agent.meta, etc. — the agent-row shape.
263
- function buildBindingFromAgentRow(agent) {
264
- const agentId = String(agent?.id || agent?.agent_id || '').trim();
265
- const runtime = agent?.service_type;
266
- const hostId = getRuntimeHostIdFromPayload(agent) || undefined;
267
- return {
268
- id: agentId,
269
- adapter: 'ticlawk',
270
- targetKey: agentId,
271
- targetMeta: { agentId, runtime_host_id: hostId },
272
- runtime_host_id: hostId,
273
- runtime_host_label: getRuntimeHostLabelFromPayload(agent) || undefined,
274
- runtime,
275
- runtimeMeta: {
276
- ...projectRuntimeMeta(agent?.meta),
277
- runtimeVersion: normalizeRuntimeVersion(agent?.runtime_version),
278
- },
279
- displayName: agent?.display_name || agent?.name || agentId,
280
- status: agent?.status || 'connected',
281
- };
282
- }
283
-
284
- // Build a binding from a claimed delivery row. Reads
285
- // recipient_agent_id, agent_service_type, agent_meta, etc. — the
286
- // claim-payload shape returned by claim_pending_deliveries.
287
- function buildBindingFromDeliveryRow(msg) {
288
- const agentId = String(msg?.recipient_agent_id || '').trim();
289
- const runtime = msg?.agent_service_type;
290
- const hostId = getRuntimeHostIdFromPayload(msg) || undefined;
286
+ // Build a binding from any source row that carries agent metadata.
287
+ // Two source shapes feed in:
288
+ // /api/agents row — id, service_type, meta,
289
+ // claim_pending_deliveries — recipient_agent_id, agent_service_type,
290
+ // agent_meta, (prefixed because the
291
+ // delivery row also carries message fields)
292
+ // We try the prefixed names first, then fall through to the bare names,
293
+ // so one builder handles both without the caller having to remember
294
+ // which shape it has.
295
+ function buildBindingFromSource(source) {
296
+ const agentId = String(
297
+ source?.recipient_agent_id
298
+ || source?.id
299
+ || source?.agent_id
300
+ || ''
301
+ ).trim();
302
+ const runtime = source?.agent_service_type || source?.service_type;
303
+ const meta = source?.agent_meta ?? source?.meta;
304
+ const runtimeVersion = source?.agent_runtime_version ?? source?.runtime_version;
305
+ const displayName = source?.agent_display_name
306
+ || source?.agent_name
307
+ || source?.display_name
308
+ || source?.name
309
+ || agentId;
310
+ const status = source?.agent_status || source?.status || 'connected';
311
+ const hostId = getRuntimeHostIdFromPayload(source) || undefined;
291
312
  return {
292
313
  id: agentId,
293
314
  adapter: 'ticlawk',
294
315
  targetKey: agentId,
295
316
  targetMeta: { agentId, runtime_host_id: hostId },
296
317
  runtime_host_id: hostId,
297
- runtime_host_label: getRuntimeHostLabelFromPayload(msg) || undefined,
318
+ runtime_host_label: getRuntimeHostLabelFromPayload(source) || undefined,
298
319
  runtime,
299
320
  runtimeMeta: {
300
- ...projectRuntimeMeta(msg?.agent_meta),
301
- runtimeVersion: normalizeRuntimeVersion(msg?.agent_runtime_version),
321
+ ...projectRuntimeMeta(meta),
322
+ runtimeVersion: normalizeRuntimeVersion(runtimeVersion),
302
323
  },
303
- displayName: msg?.agent_display_name || msg?.agent_name || agentId,
304
- status: msg?.agent_status || 'connected',
324
+ displayName,
325
+ status,
305
326
  };
306
327
  }
307
328
 
@@ -456,8 +477,6 @@ export function createTiclawkAdapter(ctx) {
456
477
  let bindingAuditTimer = null;
457
478
  let jobsWakeTimer = null;
458
479
  let bindingsWakeTimer = null;
459
- let drainPromise = null;
460
- let drainRequested = false;
461
480
  let lastJobsWakeAt = 0;
462
481
  let lastBindingsWakeAt = 0;
463
482
  let updateRequired = null;
@@ -633,7 +652,7 @@ export function createTiclawkAdapter(ctx) {
633
652
  continue;
634
653
  }
635
654
 
636
- const binding = await ctx.persistBinding(buildBindingFromDeliveryRow(msg));
655
+ const binding = await ctx.persistBinding(buildBindingFromSource(msg));
637
656
  if (!binding?.runtime) {
638
657
  throw new Error('claimed message missing runtime binding');
639
658
  }
@@ -733,7 +752,7 @@ export function createTiclawkAdapter(ctx) {
733
752
  continue;
734
753
  }
735
754
  try {
736
- await ctx.persistBinding(buildBindingFromAgentRow(agent));
755
+ await ctx.persistBinding(buildBindingFromSource(agent));
737
756
  hydrated += 1;
738
757
  } catch (err) {
739
758
  debugError('ticlawk', 'binding.hydrate-failed', {
@@ -883,23 +902,7 @@ export function createTiclawkAdapter(ctx) {
883
902
  return { totalClaimed, iterations };
884
903
  }
885
904
 
886
- function requestDrain(reason) {
887
- if (drainPromise) {
888
- drainRequested = true;
889
- return drainPromise;
890
- }
891
- drainPromise = (async () => {
892
- let currentReason = reason;
893
- do {
894
- drainRequested = false;
895
- await runDrain(currentReason);
896
- currentReason = 'drain.requested-again';
897
- } while (drainRequested);
898
- })().finally(() => {
899
- drainPromise = null;
900
- });
901
- return drainPromise;
902
- }
905
+ const requestDrain = coalesce(runDrain);
903
906
 
904
907
  function scheduleDrain(reason) {
905
908
  jobsWakeTimer = clearDebounce(jobsWakeTimer);
@@ -17,7 +17,8 @@
17
17
  * ticlawk server info [--refresh]
18
18
  *
19
19
  * `ticlawk message send` reads the message body from stdin so heredocs
20
- * work cleanly (matching the Slock convention).
20
+ * work cleanly quotes, backticks, and code blocks survive without
21
+ * shell-quoting gymnastics.
21
22
  */
22
23
 
23
24
  import { readFileSync, statSync, writeFileSync } from 'node:fs';
@@ -1,14 +1,10 @@
1
1
  /**
2
- * Per-agent slock-style home directory: ~/.ticlawk/agents/<agent_id>/
2
+ * Per-agent home directory: ~/.ticlawk/agents/<agent_id>/
3
3
  *
4
- * This is the agent's authoritative workspace. The daemon spawns every
5
- * runtime with this as cwd; the agent's MEMORY.md, notes/, and any
6
- * artifacts it produces live here. No project binding.
7
- *
8
- * Following slock's daemon model (dist/chunk-M4A5QPUN.js:5079) —
9
- * dataDir = ~/.slock/agents, agentDataDir = <dataDir>/<id>, and every
10
- * driver.spawn() receives `workingDirectory: agentDataDir`. One MEMORY.md
11
- * per agent, lives in cwd, agent reads it via `cat MEMORY.md`.
4
+ * The agent's authoritative workspace. The daemon spawns every runtime
5
+ * with this as cwd; the agent's MEMORY.md, notes/, and any artifacts
6
+ * it produces live here. No project binding — one MEMORY.md per agent,
7
+ * lives in cwd, agent reads it via `cat MEMORY.md`.
12
8
  */
13
9
 
14
10
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
- // Seed the slock-style per-agent home + MEMORY.md for every paired
3
- // agent in the linked Supabase project.
2
+ // Seed the per-agent home + MEMORY.md for every paired agent in the
3
+ // linked Supabase project.
4
4
  //
5
5
  // ~/.ticlawk/agents/<agent_id>/MEMORY.md
6
6
  //
7
- // This replaces the Phase-B variant that wrote MEMORY.md into each
8
- // agent's *project* workdir. The new design follows slock exactly:
9
- // agent cwd = its own home dir, MEMORY.md lives in cwd.
7
+ // Replaces the Phase-B variant that wrote MEMORY.md into each agent's
8
+ // project workdir. Each agent now has its own home dir as cwd, with
9
+ // MEMORY.md living at the root of that home.
10
10
  //
11
11
  // Usage:
12
12
  // node src/migrate/write-initial-memory.mjs # dry-run
@@ -1,19 +1,12 @@
1
1
  /**
2
2
  * Standing prompt injected into every runtime turn.
3
3
  *
4
- * Structurally a port of Slock's "CLI variant" system prompt
5
- * (照抄 see the upstream @slock-ai/daemon source at
6
- * dist/chunk-M4A5QPUN.js `buildPrompt`).
7
- *
8
- * Substitutions applied:
9
- * slock → ticlawk
10
- * channel → group
11
- * command surface trimmed to what Ticlawk actually exposes today
12
- *
13
- * Adapted sections deliberately preserved verbatim where they encode the
14
- * etiquette rules that make multi-agent coordination work without
15
- * runtime-level orchestration. Trim with care; this prompt has been
16
- * field-calibrated upstream.
4
+ * Encodes the agent's communication contract: how to receive messages,
5
+ * how to reply (always via the `ticlawk` CLI), how to handle ambient
6
+ * vs mention traffic, task lifecycle, and the per-agent home-dir +
7
+ * MEMORY.md workspace convention. Trim with care — the etiquette
8
+ * sections are load-bearing for multi-agent coordination without
9
+ * runtime-level orchestration.
17
10
  */
18
11
 
19
12
  const STANDING_PROMPT = `You are an agent in Ticlawk — a collaborative platform for human-AI
@@ -12,7 +12,6 @@ import { buildAgentRuntimeEnv } from '../../core/runtime-env.mjs';
12
12
  import { buildStandingPrompt } from '../_shared/standing-prompt.mjs';
13
13
  import { ensureAgentHome } from '../../core/agent-home.mjs';
14
14
  import {
15
- createCCSession,
16
15
  getClaudeCodeRuntimeHealth,
17
16
  runCCPrompt,
18
17
  streamCCPrompt,
@@ -28,7 +27,6 @@ import { normalizeOutboundMedia } from '../../core/media/outbound.mjs';
28
27
  import { emitWorkerEvent } from '../../core/events/worker-events.mjs';
29
28
  import {
30
29
  shouldStreamRuntime,
31
- sendAdapterMessage,
32
30
  recordActivity,
33
31
  reportSubprocessFailure,
34
32
  terminalRuntimeFailure,
@@ -38,15 +36,9 @@ import {
38
36
  export const claudeCodeRuntime = {
39
37
  name: 'claude_code',
40
38
 
41
- // Start a new Claude session without blocking on local transcript
42
- // discovery. The live turn path trusts stdout/session_id; transcript
43
- // indexing is a separate concern.
44
- async createSession({ projectDir, text, claudePath }) {
45
- return createCCSession({ projectDir, message: text, claudePath });
46
- },
47
-
48
- // Run a Claude turn and wait for the final result on stdout. This is
49
- // the worker-first path used by the adapter for direct reply delivery.
39
+ // Run a Claude turn and wait for the final result on stdout. Used by
40
+ // deliverTurn when streaming is disabled; both fresh sessions
41
+ // (sessionId=null) and resumed sessions go through here.
50
42
  runTurn({ sessionId, projectDir, claudePath, agentEnv }, text, opts = {}) {
51
43
  return runCCPrompt({
52
44
  sessionId,
@@ -130,109 +122,37 @@ export const claudeCodeRuntime = {
130
122
  if (!binding) return false;
131
123
  const adapter = ctx.adapter;
132
124
  const meta = binding.runtimeMeta || {};
133
- // slock-style: cwd is the per-agent home dir under ~/.ticlawk/agents/<id>/.
125
+ // cwd is the per-agent home dir under ~/.ticlawk/agents/<id>/.
134
126
  const projectDir = ensureAgentHome(binding.id, {
135
127
  displayName: binding.display_name || binding.name || null,
136
128
  });
137
- const sessionId = meta.sessionId || binding.id;
138
129
  const runtimeClaudePath = meta.claudePath || meta.runtimePath || null;
139
130
 
140
131
  const message = inbound.action === 'image'
141
132
  ? await buildImageMessageFromInbound(inbound, 'claude-code')
142
133
  : inbound.text;
143
134
 
135
+ // shouldRotate=true means meta.sessionId is missing or invalidated.
136
+ // We pass sessionId=null so `claude` creates a fresh session; the new
137
+ // session_id is captured from stream events and persisted below.
138
+ // Unifying rotate + non-rotate into one path means the standing prompt
139
+ // is always attached, so the agent uses the CLI to reply on every
140
+ // turn — including the first.
144
141
  const shouldRotate = !meta.sessionId || meta.rotatePending;
145
- if (shouldRotate) {
146
- await emitWorkerEvent({
147
- adapter,
148
- binding,
149
- agent: this.name,
150
- sessionId: sessionId || binding.id,
151
- cwd: projectDir,
152
- replyToMessageId: inbound.messageId || null,
153
- event: {
154
- hook_event_name: 'worker.turn.start',
155
- worker_event_name: 'worker.turn.start',
156
- },
157
- logger: ctx.logger,
158
- });
159
- try {
160
- const claudePath = requireClaudePath(runtimeClaudePath);
161
- const claudeVersion = getClaudeCodeRuntimeHealth(claudePath).version || meta.claudeVersion || null;
162
- const created = await this.createSession({ projectDir, text: message, claudePath });
163
- const nextBinding = await updateBindingRuntimeMeta(ctx, binding, {
164
- sessionId: created.sessionId,
165
- path: null,
166
- runtimePath: claudePath,
167
- claudePath,
168
- claudeVersion,
169
- rotatePending: false,
170
- lastRotatedAt: new Date().toISOString(),
171
- }, { status: 'connected' });
172
- if (created.resultText && created.resultText.trim()) {
173
- await sendAdapterMessage(adapter, nextBinding, {
174
- type: 'assistant',
175
- text: created.resultText,
176
- media: [],
177
- replyToMessageId: inbound.messageId || null,
178
- });
179
- }
180
- await emitWorkerEvent({
181
- adapter,
182
- binding: nextBinding,
183
- agent: this.name,
184
- sessionId: created.sessionId,
185
- cwd: projectDir,
186
- replyToMessageId: inbound.messageId || null,
187
- event: {
188
- hook_event_name: 'Stop',
189
- worker_event_name: 'worker.turn.complete',
190
- },
191
- logger: ctx.logger,
192
- });
193
- return true;
194
- } catch (err) {
195
- await emitWorkerEvent({
196
- adapter,
197
- binding,
198
- agent: this.name,
199
- sessionId: sessionId || binding.id,
200
- cwd: projectDir,
201
- replyToMessageId: inbound.messageId || null,
202
- event: {
203
- hook_event_name: 'worker.turn.error',
204
- worker_event_name: 'worker.turn.error',
205
- error: err?.message || 'Claude Code failed',
206
- },
207
- logger: ctx.logger,
208
- });
209
- await reportSubprocessFailure({
210
- adapter,
211
- binding,
212
- inbound,
213
- runtimeName: 'Claude Code',
214
- info: err?.info || {
215
- ok: false,
216
- kind: 'exit-error',
217
- errorMessage: err?.message || 'Claude Code failed',
218
- durationMs: 0,
219
- },
220
- });
221
- return terminalRuntimeFailure(err?.message || 'Claude Code failed');
222
- }
223
- }
142
+ const targetSessionId = shouldRotate ? null : meta.sessionId;
143
+ const errEventSessionId = meta.sessionId || binding.id;
224
144
 
225
145
  try {
226
146
  const claudePath = requireClaudePath(runtimeClaudePath);
227
147
  const claudeVersion = getClaudeCodeRuntimeHealth(claudePath).version || meta.claudeVersion || null;
228
148
  const agentEnv = buildAgentRuntimeEnv({
229
149
  agentId: binding.id,
230
- sessionId,
150
+ sessionId: meta.sessionId,
231
151
  hostId: binding.runtime_host_id,
232
152
  });
233
153
  const appendSystemPrompt = buildStandingPrompt({ agentId: binding.id });
234
154
  const result = shouldStreamRuntime(this.name, this)
235
- ? await this.runTurnStream({ sessionId, projectDir, claudePath, agentEnv }, message, {
155
+ ? await this.runTurnStream({ sessionId: targetSessionId, projectDir, claudePath, agentEnv }, message, {
236
156
  appendSystemPrompt,
237
157
  onEvent: async (event) => {
238
158
  if (event?.type === 'turn.started') {
@@ -240,7 +160,7 @@ export const claudeCodeRuntime = {
240
160
  adapter,
241
161
  binding,
242
162
  agent: this.name,
243
- sessionId: event.sessionId || sessionId,
163
+ sessionId: event.sessionId || targetSessionId || binding.id,
244
164
  cwd: projectDir,
245
165
  replyToMessageId: inbound.messageId || null,
246
166
  event: {
@@ -254,7 +174,7 @@ export const claudeCodeRuntime = {
254
174
  adapter,
255
175
  binding,
256
176
  agent: this.name,
257
- sessionId: event.sessionId || sessionId,
177
+ sessionId: event.sessionId || targetSessionId || binding.id,
258
178
  cwd: projectDir,
259
179
  replyToMessageId: inbound.messageId || null,
260
180
  event: {
@@ -267,12 +187,14 @@ export const claudeCodeRuntime = {
267
187
  }
268
188
  },
269
189
  })
270
- : await this.runTurn({ sessionId, projectDir, claudePath, agentEnv }, message, { appendSystemPrompt });
190
+ : await this.runTurn({ sessionId: targetSessionId, projectDir, claudePath, agentEnv }, message, { appendSystemPrompt });
271
191
  const nextBinding = await updateBindingRuntimeMeta(ctx, binding, {
272
192
  sessionId: result?.sessionId || meta.sessionId,
273
193
  runtimePath: claudePath,
274
194
  claudePath,
275
195
  claudeVersion,
196
+ rotatePending: false,
197
+ lastRotatedAt: shouldRotate ? new Date().toISOString() : meta.lastRotatedAt,
276
198
  }, { status: 'connected' });
277
199
  await recordActivity(adapter, nextBinding, inbound, {
278
200
  ...result,
@@ -282,7 +204,7 @@ export const claudeCodeRuntime = {
282
204
  adapter,
283
205
  binding: nextBinding,
284
206
  agent: this.name,
285
- sessionId: result?.sessionId || sessionId,
207
+ sessionId: result?.sessionId || targetSessionId || binding.id,
286
208
  cwd: projectDir,
287
209
  replyToMessageId: inbound.messageId || null,
288
210
  event: {
@@ -297,7 +219,7 @@ export const claudeCodeRuntime = {
297
219
  adapter,
298
220
  binding,
299
221
  agent: this.name,
300
- sessionId,
222
+ sessionId: errEventSessionId,
301
223
  cwd: projectDir,
302
224
  replyToMessageId: inbound.messageId || null,
303
225
  event: {
@@ -130,7 +130,7 @@ export const codexRuntime = {
130
130
  if (!binding) return false;
131
131
  const adapter = ctx.adapter;
132
132
  const meta = binding.runtimeMeta || {};
133
- // slock-style: cwd is the per-agent home dir under ~/.ticlawk/agents/<id>/.
133
+ // cwd is the per-agent home dir under ~/.ticlawk/agents/<id>/.
134
134
  // ensureAgentHome creates it + seeds MEMORY.md if missing.
135
135
  const agentHome = ensureAgentHome(binding.id, {
136
136
  displayName: binding.display_name || binding.name || null,