@codefilabs/tq 0.0.2 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # tq — Task Queue for Claude
2
2
 
3
- A lightweight task queue runner that spawns Claude AI tasks as independent tmux sessions.
3
+ A lightweight task queue runner and conversation manager that spawns Claude AI tasks as independent tmux sessions. Supports both one-off batch queues and persistent interactive conversations via Telegram.
4
4
 
5
5
  ## What It Does
6
6
 
@@ -31,9 +31,9 @@ Here's exactly what that command does:
31
31
 
32
32
  1. **Downloads the install script** from this repo over HTTPS (`-fsSL` = fail on error, silent, follow redirects) and pipes it directly to `bash` — nothing is saved to disk.
33
33
 
34
- 2. **Registers the tq marketplace** with Claude Code by running `claude plugin marketplace add kevnk/tq`. This clones the tq repo into `~/.claude/plugins/marketplaces/tq/` so Claude knows where to find it.
34
+ 2. **Registers the codefilabs marketplace** with Claude Code by running `claude plugin marketplace add codefilabs/marketplace`. This clones the marketplace repo into `~/.claude/plugins/marketplaces/codefilabs/` so Claude knows where to find it.
35
35
 
36
- 3. **Installs the tq plugin** by running `claude plugin install tq@tq`. This caches the plugin files into `~/.claude/plugins/cache/tq/tq/<version>/` and registers the plugin's skills and slash commands with Claude Code. After this, Claude will recognize commands like `/todo`, `/schedule`, `/jobs`, and `/health`.
36
+ 3. **Installs the tq plugin** by running `claude plugin install tq@codefilabs`. This caches the plugin files into `~/.claude/plugins/cache/tq/codefilabs/<version>/` and registers the plugin's skills and slash commands with Claude Code. After this, Claude will recognize commands like `/todo`, `/schedule`, `/jobs`, and `/health`.
37
37
 
38
38
  4. **Symlinks `tq`** into `/opt/homebrew/bin` (Apple Silicon) or `/usr/local/bin` (Intel Mac) so the command is available in your shell and in cron jobs.
39
39
 
@@ -216,6 +216,8 @@ Once installed, Claude can manage your task queues via slash commands:
216
216
  | `/health` | System-wide diagnostics |
217
217
  | `/setup-telegram` | Interactive wizard to configure Telegram notifications |
218
218
  | `/install` | Symlink tq binaries to PATH |
219
+ | `/converse [start\|stop\|status]` | Manage Telegram conversation sessions |
220
+ | `/tq-reply` | Send a response back to Telegram (used by Claude in conversation mode) |
219
221
 
220
222
  Claude will infer the queue name from context: "every morning" → `morning.yaml`, "daily" → `daily.yaml`, or the current directory's basename if no schedule keyword is present.
221
223
 
@@ -294,3 +296,98 @@ crontab -e
294
296
  Logs accumulate in `~/.tq/logs/tq.log`.
295
297
 
296
298
  See `skills/tq/references/cron-expressions.md` for a natural language → cron expression reference.
299
+
300
+ ## Conversation Mode (Telegram)
301
+
302
+ Conversation mode enables interactive, back-and-forth conversations with Claude Code via Telegram. An orchestrator Claude session routes your messages to the appropriate conversation, automatically creating new sessions for new topics and resuming existing ones.
303
+
304
+ ### Setup
305
+
306
+ 1. Configure Telegram: run `/setup-telegram` in Claude Code (or `tq-setup`)
307
+ 2. Add the polling cron job:
308
+ ```bash
309
+ * * * * * /opt/homebrew/bin/tq-telegram-poll >> ~/.tq/logs/tq-telegram.log 2>&1
310
+ ```
311
+
312
+ ### Starting Conversations
313
+
314
+ Send `/converse` from Telegram (or run `tq-converse start` from CLI). This launches the orchestrator.
315
+
316
+ Once the orchestrator is running, just send messages normally. The orchestrator will:
317
+ - **New topic** → create a new conversation session with a descriptive slug (e.g., `fix-auth-bug`)
318
+ - **Related to existing** → route to the appropriate session
319
+ - **Telegram reply** → automatically route to the conversation that sent the original message
320
+ - **Explicit routing** → prefix with `#slug-name` to target a specific session
321
+
322
+ ### Example Flow
323
+
324
+ ```
325
+ You: "fix the login bug in the auth module"
326
+ tq: [new: fix-auth] Started conversation: Fix login bug in auth module
327
+
328
+ You: "what did you find?"
329
+ tq: [fix-auth] Found 3 issues in src/auth.py: ...
330
+
331
+ You: "refactor the payment service to use Stripe v2"
332
+ tq: [new: refactor-payments] Started conversation: Refactor payment service for Stripe v2
333
+
334
+ You: #fix-auth "also check the password reset flow"
335
+ tq: [fix-auth] Checking password reset flow...
336
+ ```
337
+
338
+ ### Telegram Commands
339
+
340
+ | Command | Purpose |
341
+ |---------|---------|
342
+ | `/converse` | Start the orchestrator |
343
+ | `/stop` | Stop the orchestrator |
344
+ | `/stop <slug>` | Stop a specific conversation |
345
+ | `/status` | Show all sessions |
346
+ | `/list` | List active conversations |
347
+
348
+ ### How It Works
349
+
350
+ 1. **tq-telegram-poll** runs every minute via cron, fetches new Telegram messages
351
+ 2. **3-tier routing** determines where each message goes:
352
+ - Tier 1: Telegram reply → deterministic lookup via registry message ID mapping
353
+ - Tier 2: `#slug` prefix → route directly to named session
354
+ - Tier 3: Send to orchestrator Claude for smart routing
355
+ 3. **Orchestrator** (a persistent Claude Code session) reads the conversation registry, decides whether to route to an existing session or spawn a new one
356
+ 4. **Child sessions** (each a persistent Claude Code interactive session) process messages and respond via `/tq-reply`
357
+ 5. **Responses** are sent back to Telegram as threaded replies, with the session slug as a label
358
+
359
+ ### State
360
+
361
+ Conversation state lives in `~/.tq/conversations/`:
362
+
363
+ ```
364
+ ~/.tq/conversations/
365
+ ├── registry.json ← session registry (slugs, descriptions, message IDs)
366
+ ├── orchestrator/ ← orchestrator Claude settings and instructions
367
+ │ ├── .tq-orchestrator.md
368
+ │ ├── settings.json
369
+ │ └── hooks/
370
+ └── sessions/
371
+ ├── fix-auth/ ← per-session state
372
+ │ ├── .tq-converse.md
373
+ │ ├── settings.json
374
+ │ ├── current-slug
375
+ │ ├── reply-to-msg-id
376
+ │ ├── inbox/ ← received messages (timestamped)
377
+ │ └── outbox/ ← sent responses (timestamped)
378
+ └── refactor-payments/
379
+ └── ...
380
+ ```
381
+
382
+ ### CLI Commands
383
+
384
+ ```bash
385
+ tq-converse start # start orchestrator
386
+ tq-converse spawn <slug> [opts] # create a child session
387
+ tq-converse route <slug> <message> # send to a session
388
+ tq-converse send <message> # send to orchestrator
389
+ tq-converse list # list active sessions
390
+ tq-converse status # show all session details
391
+ tq-converse stop [<slug>] # stop session or orchestrator
392
+ tq-converse registry # dump the session registry
393
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codefilabs/tq",
3
- "version": "0.0.2",
3
+ "version": "0.1.0",
4
4
  "description": "A lightweight task queue runner that spawns Claude AI tasks as independent tmux sessions",
5
5
  "keywords": [
6
6
  "claude",
@@ -37,5 +37,8 @@
37
37
  },
38
38
  "publishConfig": {
39
39
  "access": "public"
40
+ },
41
+ "scripts": {
42
+ "postpublish": "node tools/update-marketplace.js"
40
43
  }
41
44
  }
package/scripts/tq CHANGED
@@ -10,6 +10,7 @@ PROMPT_TEXT=""
10
10
  TASK_NAME="adhoc"
11
11
  TASK_CWD=""
12
12
  NOTIFY=""
13
+ CHROME=1
13
14
 
14
15
  while [[ $# -gt 0 ]]; do
15
16
  case "${1:-}" in
@@ -18,6 +19,7 @@ while [[ $# -gt 0 ]]; do
18
19
  --name) TASK_NAME="${2:-}"; shift 2 ;;
19
20
  --cwd) TASK_CWD="${2:-}"; shift 2 ;;
20
21
  --notify) NOTIFY="${2:-}"; shift 2 ;;
22
+ --no-chrome) CHROME=0; shift ;;
21
23
  --) shift; break ;;
22
24
  -*) echo "Unknown flag: $1" >&2; exit 1 ;;
23
25
  *) break ;;
@@ -92,6 +94,7 @@ if [[ "$STATUS_MODE" == "1" ]]; then
92
94
  if [[ "$STATUS" == "running" ]]; then
93
95
  if ! tmux has-session -t "$SESSION" 2>/dev/null; then
94
96
  sed -i '' 's/^status=running/status=done/' "$STATE_FILE"
97
+ echo "completed=$(date +%s)" >> "$STATE_FILE"
95
98
  STATUS="done"
96
99
  fi
97
100
  fi
@@ -125,7 +128,7 @@ fi
125
128
  # (Using a temp file avoids bash 3.2 bug: single quotes inside <<'HEREDOC'
126
129
  # inside $() are incorrectly scanned by the quote counter.)
127
130
  # ---------------------------------------------------------------------------
128
- PARSE_SCRIPT=$(mktemp /tmp/tq-parse-XXXXXX.py)
131
+ PARSE_SCRIPT=$(mktemp /tmp/tq-parse-XXXXXX)
129
132
  trap 'rm -f "$PARSE_SCRIPT"' EXIT
130
133
 
131
134
  cat > "$PARSE_SCRIPT" <<'PYEOF'
@@ -136,7 +139,7 @@ os.makedirs(sessions_dir, exist_ok=True)
136
139
 
137
140
  # Support direct prompt mode: --prompt <text> <state_dir> [cwd]
138
141
  if len(sys.argv) > 1 and sys.argv[1] == '--prompt':
139
- tasks = [(sys.argv[2].strip(), sys.argv[4] if len(sys.argv) > 4 else '')]
142
+ tasks = [(sys.argv[2].strip(), sys.argv[4] if len(sys.argv) > 4 else '', '', '')]
140
143
  state_dir = sys.argv[3]
141
144
  else:
142
145
  queue_file = sys.argv[1]
@@ -155,11 +158,62 @@ else:
155
158
  cwd = m.group(1).strip().strip('"\'')
156
159
  break
157
160
 
161
+ # Extract top-level reset policy
162
+ reset_mode = ''
163
+ for line in lines:
164
+ m = re.match(r'^reset:\s*(.+)$', line)
165
+ if m:
166
+ reset_mode = m.group(1).strip().strip('"\'')
167
+ break
168
+
169
+ # Handle queue-level resets before task evaluation.
170
+ # Clears task state files (and .queue-notified) so tasks re-run.
171
+ # daily — reset once per calendar day
172
+ # weekly — reset once per ISO week (Mon–Sun)
173
+ # hourly — reset once per hour
174
+ # always — reset on every tq run
175
+ if reset_mode in ('daily', 'weekly', 'hourly', 'always'):
176
+ import datetime
177
+ now = datetime.datetime.now()
178
+ if reset_mode == 'daily':
179
+ period = now.date().isoformat() # e.g. '2026-03-11'
180
+ elif reset_mode == 'weekly':
181
+ period = now.strftime('%G-W%V') # e.g. '2026-W11'
182
+ elif reset_mode == 'hourly':
183
+ period = now.strftime('%Y-%m-%dT%H') # e.g. '2026-03-11T17'
184
+ else: # always
185
+ period = None
186
+ last_reset_file = os.path.join(state_dir, '.last_reset')
187
+ last_reset = ''
188
+ if period is not None and os.path.exists(last_reset_file):
189
+ with open(last_reset_file) as f:
190
+ last_reset = f.read().strip()
191
+ if period is None or last_reset != period:
192
+ for fname in os.listdir(state_dir):
193
+ fpath = os.path.join(state_dir, fname)
194
+ if not fname.startswith('.') and '.' not in fname:
195
+ os.remove(fpath) # task state file (bare hash)
196
+ elif fname == '.queue-notified':
197
+ os.remove(fpath) # clear so completion fires fresh
198
+ if period is not None:
199
+ with open(last_reset_file, 'w') as f:
200
+ f.write(period)
201
+
158
202
  # Parse tasks -- handle inline, block literal (|), block folded (>), quoted
159
203
  tasks = []
204
+ current_name = ''
160
205
  i = 0
161
206
  while i < len(lines):
162
- m = re.match(r'^ - prompt:\s*(.*)', lines[i])
207
+ # Reset name at start of each new list item
208
+ if re.match(r'^ - ', lines[i]) and not re.match(r'^ - prompt:', lines[i]):
209
+ current_name = ''
210
+ m_name = re.match(r'^ - name:\s*(.*)', lines[i])
211
+ if m_name:
212
+ current_name = m_name.group(1).strip().strip('"\'')
213
+ i += 1
214
+ continue
215
+
216
+ m = re.match(r'^(?: - | )prompt:\s*(.*)', lines[i])
163
217
  if not m:
164
218
  i += 1
165
219
  continue
@@ -209,9 +263,10 @@ else:
209
263
 
210
264
  prompt = prompt.strip()
211
265
  if prompt:
212
- tasks.append((prompt, cwd))
266
+ tasks.append((prompt, cwd, current_name, reset_mode))
267
+ current_name = ''
213
268
 
214
- for (prompt, cwd) in tasks:
269
+ for (prompt, cwd, name, reset_mode) in tasks:
215
270
  h = hashlib.sha256(prompt.encode()).hexdigest()[:8]
216
271
  first_line = prompt.split('\n')[0][:80]
217
272
 
@@ -252,8 +307,15 @@ for (prompt, cwd) in tasks:
252
307
  stop_script += 'set -euo pipefail\n'
253
308
  stop_script += '# Mark tq task done\n'
254
309
  stop_script += 'STATE_FILE=' + json.dumps(state_file) + '\n'
310
+ stop_script += 'RESET_MODE=' + json.dumps(reset_mode) + '\n'
255
311
  stop_script += 'if [[ -f "$STATE_FILE" ]]; then\n'
256
- stop_script += " sed -i '' 's/^status=running/status=done/' \"$STATE_FILE\"\n"
312
+ stop_script += ' if [[ "$RESET_MODE" == "on-complete" ]]; then\n'
313
+ stop_script += ' # on-complete: tq-message runs first, then state is cleared for re-run\n'
314
+ stop_script += ' : # state deletion happens after tq-message below\n'
315
+ stop_script += ' else\n'
316
+ stop_script += " sed -i '' 's/^status=running/status=done/' \"$STATE_FILE\"\n"
317
+ stop_script += ' echo "completed=$(date +%s)" >> "$STATE_FILE"\n'
318
+ stop_script += ' fi\n'
257
319
  stop_script += 'fi\n'
258
320
  if notify:
259
321
  stop_script += '\n# Notification (--notify)\n'
@@ -283,6 +345,10 @@ for (prompt, cwd) in tasks:
283
345
  stop_script += ' SESSION="$(grep \'^session=\' "$STATE_FILE" | cut -d= -f2)"\n'
284
346
  stop_script += ' tq-message --task "$TQ_HASH" --queue "$TQ_QUEUE_FILE" --state-file "$STATE_FILE" --session "$SESSION"\n'
285
347
  stop_script += 'fi\n'
348
+ stop_script += '# on-complete: delete state file so next tq run re-spawns this task\n'
349
+ stop_script += 'if [[ "$RESET_MODE" == "on-complete" && -f "$STATE_FILE" ]]; then\n'
350
+ stop_script += ' rm -f "$STATE_FILE"\n'
351
+ stop_script += 'fi\n'
286
352
  with open(stop_hook, 'w') as f:
287
353
  f.write(stop_script)
288
354
  os.chmod(stop_hook, stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH)
@@ -322,23 +388,34 @@ for (prompt, cwd) in tasks:
322
388
  f.write('os.environ[' + json.dumps(k) + '] = ' + json.dumps(v) + '\n')
323
389
  f.write('if cwd:\n')
324
390
  f.write(' os.chdir(cwd)\n')
391
+ use_chrome = os.environ.get('TQ_CHROME', '1') == '1'
392
+
325
393
  f.write('import shutil, subprocess, time\n')
326
394
  f.write('prompt = open(prompt_file).read()\n')
327
- f.write('# Open Chrome with Profile 5 (halbotkirchner@gmail.com) before connecting\n')
328
- f.write('subprocess.Popen(["open", "-a", "Google Chrome", "--args", "--profile-directory=Profile 5"])\n')
329
- f.write('time.sleep(2)\n')
395
+ if use_chrome:
396
+ f.write('# Open Chrome with Profile 5 (halbotkirchner@gmail.com) before connecting\n')
397
+ f.write('subprocess.Popen(["open", "-a", "Google Chrome", "--args", "--profile-directory=Profile 5"])\n')
398
+ f.write('time.sleep(2)\n')
399
+
400
+ # Build claude args
401
+ claude_args = "['claude', '--settings', settings_file, '--dangerously-skip-permissions'"
402
+ if use_chrome:
403
+ claude_args += ", '--chrome'"
404
+ claude_args += ", prompt]"
405
+
330
406
  f.write('# Use reattach-to-user-namespace if available (macOS tmux keychain fix)\n')
331
407
  f.write('reattach = shutil.which("reattach-to-user-namespace")\n')
332
408
  f.write('if reattach:\n')
333
- f.write(" os.execvp(reattach, [reattach, 'claude', '--settings', settings_file, '--dangerously-skip-permissions', '--chrome', prompt])\n")
409
+ f.write(" os.execvp(reattach, [reattach, " + claude_args[1:] + ")\n")
334
410
  f.write('else:\n')
335
- f.write(" os.execvp('claude', ['claude', '--settings', settings_file, '--dangerously-skip-permissions', '--chrome', prompt])\n")
411
+ f.write(" os.execvp('claude', " + claude_args + ")\n")
336
412
 
337
- print(json.dumps({'hash': h, 'first_line': first_line}))
413
+ print(json.dumps({'hash': h, 'first_line': first_line, 'name': name, 'reset': reset_mode}))
338
414
 
339
415
  PYEOF
340
416
 
341
417
  export TQ_NOTIFY="$NOTIFY"
418
+ export TQ_CHROME="$CHROME"
342
419
 
343
420
  if [[ "$PROMPT_MODE" == "1" ]]; then
344
421
  TASK_CWD="${TASK_CWD:-$HOME/.tq/workspace}"
@@ -363,6 +440,8 @@ fi
363
440
  while IFS= read -r JSON_LINE; do
364
441
  HASH="$(python3 -c "import sys,json; print(json.loads(sys.argv[1])['hash'])" "$JSON_LINE")"
365
442
  FIRST_LINE="$(python3 -c "import sys,json; print(json.loads(sys.argv[1])['first_line'])" "$JSON_LINE")"
443
+ TASK_NAME_FIELD="$(python3 -c "import sys,json; print(json.loads(sys.argv[1]).get('name', ''))" "$JSON_LINE")"
444
+ RESET_MODE="$(python3 -c "import sys,json; print(json.loads(sys.argv[1]).get('reset', ''))" "$JSON_LINE")"
366
445
  STATE_FILE="$STATE_DIR/$HASH"
367
446
  LAUNCHER="$STATE_DIR/$HASH.launch.py"
368
447
 
@@ -370,8 +449,33 @@ while IFS= read -r JSON_LINE; do
370
449
  if [[ -f "$STATE_FILE" ]]; then
371
450
  STATUS="$(grep '^status=' "$STATE_FILE" | cut -d= -f2)"
372
451
  if [[ "$STATUS" == "done" ]]; then
373
- echo " [done] $FIRST_LINE"
374
- continue
452
+ if [[ -n "$RESET_MODE" && "$RESET_MODE" != "on-complete" ]]; then
453
+ COMPLETED="$(grep '^completed=' "$STATE_FILE" | cut -d= -f2)"
454
+ NOW="$(date +%s)"
455
+ TTL_SECONDS="$(python3 -c "
456
+ import sys
457
+ s = sys.argv[1]
458
+ if s.endswith('h'):
459
+ print(int(s[:-1]) * 3600)
460
+ elif s.endswith('d'):
461
+ print(int(s[:-1]) * 86400)
462
+ elif s.endswith('m'):
463
+ print(int(s[:-1]) * 60)
464
+ else:
465
+ print(0)
466
+ " "$RESET_MODE")"
467
+ if [[ -n "$COMPLETED" && "$TTL_SECONDS" -gt 0 && $(( NOW - COMPLETED )) -gt "$TTL_SECONDS" ]]; then
468
+ rm -f "$STATE_FILE"
469
+ echo " [reset] $FIRST_LINE (TTL expired)"
470
+ # Fall through to spawn logic below
471
+ else
472
+ echo " [done] $FIRST_LINE"
473
+ continue
474
+ fi
475
+ else
476
+ echo " [done] $FIRST_LINE"
477
+ continue
478
+ fi
375
479
  fi
376
480
  if [[ "$STATUS" == "running" ]]; then
377
481
  SESSION="$(grep '^session=' "$STATE_FILE" | cut -d= -f2)"
@@ -387,11 +491,16 @@ while IFS= read -r JSON_LINE; do
387
491
  fi
388
492
  fi
389
493
 
390
- # Generate session/window names from first line of prompt
494
+ # Generate session/window names: prefer YAML name field, fall back to prompt words
391
495
  EPOCH_SUFFIX="$(date +%s | tail -c 6)"
392
- SESSION_BASE="$(echo "$FIRST_LINE" | awk '{print $1" "$2" "$3}' | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' '-' | sed 's/^-*//' | sed 's/-*$//' | cut -c1-20)"
496
+ if [[ -n "$TASK_NAME_FIELD" ]]; then
497
+ SESSION_BASE="$(echo "$TASK_NAME_FIELD" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' '-' | sed 's/^-*//' | sed 's/-*$//' | cut -c1-20)"
498
+ WINDOW="$(echo "$TASK_NAME_FIELD" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' '-' | sed 's/^-*//' | sed 's/-*$//' | cut -c1-15)"
499
+ else
500
+ SESSION_BASE="$(echo "$FIRST_LINE" | awk '{print $1" "$2" "$3}' | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' '-' | sed 's/^-*//' | sed 's/-*$//' | cut -c1-20)"
501
+ WINDOW="$(echo "$FIRST_LINE" | awk '{print $1" "$2}' | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' '-' | sed 's/^-*//' | sed 's/-*$//' | cut -c1-15)"
502
+ fi
393
503
  SESSION="tq-${SESSION_BASE}-${EPOCH_SUFFIX}"
394
- WINDOW="$(echo "$FIRST_LINE" | awk '{print $1" "$2}' | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' '-' | sed 's/^-*//' | sed 's/-*$//' | cut -c1-15)"
395
504
 
396
505
  # Write state file
397
506
  cat > "$STATE_FILE" <<EOF