@codefilabs/tq 0.0.2 → 0.1.1

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.
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Ensure homebrew binaries are available (cron has minimal PATH)
5
+ export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
6
+
7
+ INTERVAL=20
8
+
9
+ while [[ $# -gt 0 ]]; do
10
+ case "${1:-}" in
11
+ --interval)
12
+ if [[ $# -lt 2 ]]; then
13
+ echo "Error: --interval requires a value" >&2; exit 1
14
+ fi
15
+ INTERVAL="$2"; shift; shift ;;
16
+ *) echo "Unknown flag: $1" >&2; exit 1 ;;
17
+ esac
18
+ done
19
+
20
+ QUEUES_DIR="${HOME}/.tq/queues"
21
+ LOGS_DIR="${HOME}/.tq/logs"
22
+ mkdir -p "$QUEUES_DIR" "$LOGS_DIR"
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Scan ~/.tq/queues/*.yaml for schedule: keys via embedded Python temp file
26
+ # ---------------------------------------------------------------------------
27
+ SCAN_SCRIPT=$(mktemp /tmp/tq-scan-XXXXXX)
28
+ CRONTAB_NEW=$(mktemp /tmp/tq-crontab-XXXXXX)
29
+ trap 'rm -f "$SCAN_SCRIPT" "$CRONTAB_NEW"' EXIT
30
+
31
+ cat > "$SCAN_SCRIPT" <<'PYEOF'
32
+ import sys, os, re, json, glob
33
+
34
+ queues_dir = sys.argv[1]
35
+ results = []
36
+
37
+ for yaml_path in sorted(glob.glob(os.path.join(queues_dir, '*.yaml'))):
38
+ name = os.path.basename(yaml_path)[:-5] # strip .yaml
39
+ schedule = None
40
+ try:
41
+ with open(yaml_path) as f:
42
+ for line in f:
43
+ m = re.match(r'^schedule:\s*["\']?([^"\'#\n]+?)["\']?\s*$', line)
44
+ if m:
45
+ schedule = m.group(1).strip()
46
+ break
47
+ except Exception:
48
+ pass
49
+ if schedule:
50
+ results.append({'name': name, 'schedule': schedule, 'path': yaml_path})
51
+
52
+ for r in results:
53
+ print(json.dumps(r))
54
+ PYEOF
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Detect installed tq binary path
58
+ # ---------------------------------------------------------------------------
59
+ if [[ -x "/opt/homebrew/bin/tq" ]]; then
60
+ TQ_BIN="/opt/homebrew/bin/tq"
61
+ SELF_BIN="/opt/homebrew/bin/tq-cron-sync"
62
+ elif [[ -x "/usr/local/bin/tq" ]]; then
63
+ TQ_BIN="/usr/local/bin/tq"
64
+ SELF_BIN="/usr/local/bin/tq-cron-sync"
65
+ else
66
+ echo "Error: tq not found in /opt/homebrew/bin or /usr/local/bin" >&2
67
+ exit 1
68
+ fi
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Read existing crontab, strip all tq-managed lines into temp file
72
+ # ---------------------------------------------------------------------------
73
+ { crontab -l 2>/dev/null || true; } | grep -v '# tq-managed:' > "$CRONTAB_NEW" || true
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # Append new managed lines for each scheduled queue
77
+ # ---------------------------------------------------------------------------
78
+ while IFS= read -r JSON_LINE; do
79
+ [[ -z "$JSON_LINE" ]] && continue
80
+ NAME="$(python3 -c "import sys,json; print(json.loads(sys.argv[1])['name'])" "$JSON_LINE")"
81
+ SCHEDULE="$(python3 -c "import sys,json; print(json.loads(sys.argv[1])['schedule'])" "$JSON_LINE")"
82
+ YAML_PATH="$(python3 -c "import sys,json; print(json.loads(sys.argv[1])['path'])" "$JSON_LINE")"
83
+ echo "${SCHEDULE} ${TQ_BIN} ${YAML_PATH} >> ${LOGS_DIR}/tq.log 2>&1 # tq-managed:${NAME}:run" >> "$CRONTAB_NEW"
84
+ echo "*/30 * * * * ${TQ_BIN} --status ${YAML_PATH} >> ${LOGS_DIR}/tq.log 2>&1 # tq-managed:${NAME}:status" >> "$CRONTAB_NEW"
85
+ done < <(python3 "$SCAN_SCRIPT" "$QUEUES_DIR")
86
+
87
+ # Self-watcher entry
88
+ echo "*/${INTERVAL} * * * * ${SELF_BIN} >> ${LOGS_DIR}/tq-cron-sync.log 2>&1 # tq-managed:tq-cron-sync" >> "$CRONTAB_NEW"
89
+
90
+ # Write merged crontab
91
+ crontab "$CRONTAB_NEW"
92
+ echo "tq-cron-sync: crontab synced ($(grep -c '# tq-managed:' "$CRONTAB_NEW" || true) managed entries)"
@@ -5,10 +5,10 @@ set -euo pipefail
5
5
  # Step 1: Register marketplace and install Claude plugin
6
6
  # ---------------------------------------------------------------------------
7
7
  if command -v claude &>/dev/null; then
8
- echo "Adding tq marketplace..."
9
- claude plugin marketplace add kevnk/tq
8
+ echo "Adding codefilabs marketplace..."
9
+ claude plugin marketplace add codefilabs/marketplace
10
10
  echo "Installing tq plugin..."
11
- claude plugin install tq@tq
11
+ claude plugin install tq@codefilabs
12
12
  else
13
13
  echo "Warning: 'claude' CLI not found — skipping plugin registration." >&2
14
14
  echo " Install Claude Code first: https://claude.ai/code" >&2
@@ -24,11 +24,11 @@ elif [[ -n "${BASH_SOURCE[0]:-}" && -f "${BASH_SOURCE[0]}" ]]; then
24
24
  PLUGIN_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
25
25
  else
26
26
  # Running via curl | bash — find the installed plugin cache
27
- CACHE_DIR="$HOME/.claude/plugins/cache/tq/tq"
27
+ CACHE_DIR="$HOME/.claude/plugins/cache/tq/codefilabs"
28
28
  PLUGIN_ROOT="$(ls -d "$CACHE_DIR"/*/ 2>/dev/null | sort -V | tail -1)"
29
29
  if [[ -z "${PLUGIN_ROOT:-}" ]]; then
30
30
  echo "Error: could not locate tq plugin files in $CACHE_DIR" >&2
31
- echo " Try running: claude plugin marketplace add kevnk/tq && claude plugin install tq@tq" >&2
31
+ echo " Try running: claude plugin marketplace add codefilabs/marketplace && claude plugin install tq@codefilabs" >&2
32
32
  exit 1
33
33
  fi
34
34
  fi
@@ -48,7 +48,7 @@ else
48
48
  exit 1
49
49
  fi
50
50
 
51
- for SCRIPT in tq tq-message tq-setup tq-telegram-poll tq-telegram-watchdog; do
51
+ for SCRIPT in tq tq-message tq-setup tq-telegram-poll tq-telegram-watchdog tq-cron-sync tq-converse; do
52
52
  SRC="$PLUGIN_ROOT/scripts/$SCRIPT"
53
53
  DEST="$INSTALL_DIR/$SCRIPT"
54
54
  if [[ -L "$DEST" ]]; then
@@ -63,16 +63,80 @@ done
63
63
 
64
64
  mkdir -p ~/.tq/queues ~/.tq/logs ~/.tq/config
65
65
 
66
+ # Run initial cron sync to pick up any existing queue schedules
67
+ # Use $INSTALL_DIR directly — PATH may not reflect the just-created symlink yet
68
+ "$INSTALL_DIR/tq-cron-sync" --interval 20
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Step 4: Set up Telegram long-poll daemon via launchd (if bot is configured)
72
+ # ---------------------------------------------------------------------------
73
+ PLIST_LABEL="com.codefi.tq-telegram"
74
+ PLIST_PATH="$HOME/Library/LaunchAgents/${PLIST_LABEL}.plist"
75
+
76
+ if [[ -f "$HOME/.tq/config/message.yaml" ]] && grep -q 'bot_token' "$HOME/.tq/config/message.yaml" 2>/dev/null; then
77
+ # Remove old cron-based poll entries (replaced by launchd daemon)
78
+ CRONTAB_CURRENT="$(crontab -l 2>/dev/null || true)"
79
+ if echo "$CRONTAB_CURRENT" | grep -q 'tq-telegram-poll'; then
80
+ echo "$CRONTAB_CURRENT" | grep -v 'tq-telegram-poll' | grep -v 'tq-telegram-watchdog' | crontab -
81
+ echo " removed cron-based telegram poll (replaced by launchd daemon)"
82
+ fi
83
+
84
+ cat > "$PLIST_PATH" <<PLISTEOF
85
+ <?xml version="1.0" encoding="UTF-8"?>
86
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
87
+ <plist version="1.0">
88
+ <dict>
89
+ <key>Label</key>
90
+ <string>${PLIST_LABEL}</string>
91
+ <key>ProgramArguments</key>
92
+ <array>
93
+ <string>${INSTALL_DIR}/tq-telegram-poll</string>
94
+ <string>--daemon</string>
95
+ </array>
96
+ <key>RunAtLoad</key>
97
+ <true/>
98
+ <key>KeepAlive</key>
99
+ <true/>
100
+ <key>StandardOutPath</key>
101
+ <string>${HOME}/.tq/logs/tq-telegram.log</string>
102
+ <key>StandardErrorPath</key>
103
+ <string>${HOME}/.tq/logs/tq-telegram.log</string>
104
+ <key>EnvironmentVariables</key>
105
+ <dict>
106
+ <key>PATH</key>
107
+ <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
108
+ </dict>
109
+ </dict>
110
+ </plist>
111
+ PLISTEOF
112
+
113
+ # (Re)load the daemon
114
+ launchctl bootout "gui/$(id -u)/${PLIST_LABEL}" 2>/dev/null || true
115
+ launchctl bootstrap "gui/$(id -u)" "$PLIST_PATH"
116
+ echo " telegram daemon started (launchd: ${PLIST_LABEL})"
117
+ else
118
+ echo ""
119
+ echo "Telegram bot not configured yet. After running tq-setup, re-run this"
120
+ echo "installer to start the long-poll daemon automatically."
121
+ fi
122
+
123
+ echo ""
124
+ echo "tq installed. Cron schedules are managed automatically."
125
+ echo ""
126
+ echo "Add a schedule: key to any queue file in ~/.tq/queues/ to auto-schedule it:"
66
127
  echo ""
67
- echo "tq installed. Crontab example (crontab -e):"
128
+ echo " schedule: \"0 9 * * *\" # runs daily at 9am"
129
+ echo " cwd: /path/to/project"
130
+ echo " tasks: ..."
68
131
  echo ""
69
- echo " 0 9 * * * /opt/homebrew/bin/tq ~/.tq/queues/morning.yaml >> ~/.tq/logs/tq.log 2>&1"
70
- echo " */30 * * * * /opt/homebrew/bin/tq --status ~/.tq/queues/morning.yaml >> ~/.tq/logs/tq.log 2>&1"
132
+ echo "tq-cron-sync runs every 20 minutes and syncs all queue schedules to crontab."
133
+ echo "To change the sync interval: tq-cron-sync --interval <minutes>"
71
134
  echo ""
72
135
  echo "To configure Telegram notifications:"
73
136
  echo " tq-setup"
74
137
  echo ""
75
138
  echo "Or from Claude Code: /setup-telegram"
76
139
  echo ""
77
- echo "To relay Telegram messages as tq tasks, add to crontab:"
78
- echo " * * * * * /opt/homebrew/bin/tq-telegram-poll >> ~/.tq/logs/tq-telegram.log 2>&1"
140
+ echo "Conversation mode (interactive Telegram <-> Claude Code):"
141
+ echo " tq-converse start [--cwd /path/to/project]"
142
+ echo " Or send /converse from Telegram"
@@ -8,6 +8,7 @@ QUEUE_FILE=""
8
8
  MESSAGE_TEXT=""
9
9
  SESSION=""
10
10
  EXPLICIT_STATE_FILE=""
11
+ REPLY_TO_MSG_ID=""
11
12
 
12
13
  while [[ $# -gt 0 ]]; do
13
14
  case "${1:-}" in
@@ -16,15 +17,18 @@ while [[ $# -gt 0 ]]; do
16
17
  --message) MESSAGE_TEXT="${2:-}"; shift 2 ;;
17
18
  --session) SESSION="${2:-}"; shift 2 ;;
18
19
  --state-file) EXPLICIT_STATE_FILE="${2:-}"; shift 2 ;;
20
+ --reply-to) REPLY_TO_MSG_ID="${2:-}"; shift 2 ;;
19
21
  --) shift; break ;;
20
22
  -*) echo "Unknown flag: $1" >&2; exit 1 ;;
21
23
  *) break ;;
22
24
  esac
23
25
  done
24
26
 
25
- if [[ -z "$TASK_HASH" && -z "$QUEUE_FILE" ]]; then
27
+ # Allow --message without --task/--queue (conversation mode)
28
+ if [[ -z "$TASK_HASH" && -z "$QUEUE_FILE" && -z "$MESSAGE_TEXT" ]]; then
26
29
  echo "Usage: tq-message --task <hash> --queue <file.yaml> [--message <text>] [--session <name>]" >&2
27
30
  echo " tq-message --queue <file.yaml> [--message <text>]" >&2
31
+ echo " tq-message --message <text> [--reply-to <msg_id>]" >&2
28
32
  exit 1
29
33
  fi
30
34
 
@@ -36,7 +40,7 @@ if [[ -n "$QUEUE_FILE" ]]; then
36
40
  fi
37
41
  fi
38
42
 
39
- CONFIG_SCRIPT=$(mktemp /tmp/tq-message-config-XXXXXX.py)
43
+ CONFIG_SCRIPT=$(mktemp /tmp/tq-message-config-XXXXXX)
40
44
  trap 'rm -f "$CONFIG_SCRIPT"' EXIT
41
45
 
42
46
  cat > "$CONFIG_SCRIPT" <<'PYEOF'
@@ -165,21 +169,25 @@ build_message() {
165
169
  if [[ -f "$state_file" ]]; then
166
170
  status="$(grep '^status=' "$state_file" | cut -d= -f2)"
167
171
  started="$(grep '^started=' "$state_file" | cut -d= -f2)"
172
+ completed_epoch="$(grep '^completed=' "$state_file" | cut -d= -f2)"
168
173
  else
169
174
  status="done"
170
175
  started=""
176
+ completed_epoch=""
171
177
  fi
172
178
 
173
- # Calculate duration in minutes
179
+ # Calculate duration using completed epoch (avoids timezone skew from ISO string parsing)
174
180
  duration=""
175
181
  if [[ -n "$started" ]]; then
176
- start_epoch="$(python3 -c "import datetime; print(int(datetime.datetime.fromisoformat('${started}').timestamp()))" 2>/dev/null || echo "")"
177
- if [[ -n "$start_epoch" ]]; then
178
- now_epoch="$(date +%s)"
179
- elapsed=$(( now_epoch - start_epoch ))
180
- duration="${elapsed}s"
181
- if (( elapsed >= 60 )); then
182
- duration="$(( elapsed / 60 ))m $(( elapsed % 60 ))s"
182
+ start_epoch="$(date -j -f "%Y-%m-%dT%H:%M:%S" "${started}" "+%s" 2>/dev/null || echo "")"
183
+ end_epoch="${completed_epoch:-$(date +%s)}"
184
+ if [[ -n "$start_epoch" && -n "$end_epoch" ]]; then
185
+ elapsed=$(( end_epoch - start_epoch ))
186
+ if (( elapsed > 0 )); then
187
+ duration="${elapsed}s"
188
+ if (( elapsed >= 60 )); then
189
+ duration="$(( elapsed / 60 ))m $(( elapsed % 60 ))s"
190
+ fi
183
191
  fi
184
192
  fi
185
193
  fi
@@ -271,16 +279,50 @@ send_telegram() {
271
279
  local token="$1"
272
280
  local chat_id="$2"
273
281
  local text="$3"
282
+ local reply_to="${4:-}"
283
+
284
+ local curl_args=()
285
+ curl_args+=(-s -X POST "https://api.telegram.org/bot${token}/sendMessage")
286
+ curl_args+=(-d "chat_id=${chat_id}")
287
+ curl_args+=(--data-urlencode "text=${text}")
288
+ curl_args+=(-d "parse_mode=Markdown")
289
+
290
+ # Add reply threading if a reply_to message ID is provided
291
+ if [[ -n "$reply_to" ]]; then
292
+ curl_args+=(-d "reply_to_message_id=${reply_to}")
293
+ curl_args+=(-d "allow_sending_without_reply=true")
294
+ fi
274
295
 
275
296
  local response
276
- response="$(curl -s -X POST \
277
- "https://api.telegram.org/bot${token}/sendMessage" \
278
- -d "chat_id=${chat_id}" \
279
- --data-urlencode "text=${text}" \
280
- -d "parse_mode=Markdown" 2>&1)"
281
-
282
- if echo "$response" | python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if d.get('ok') else 1)" 2>/dev/null; then
283
- echo "[tq-message] sent via Telegram"
297
+ response="$(curl "${curl_args[@]}" 2>&1)"
298
+
299
+ # Extract the sent message ID for reply threading tracking
300
+ local sent_msg_id
301
+ sent_msg_id="$(echo "$response" | python3 -c "
302
+ import sys, json
303
+ try:
304
+ d = json.load(sys.stdin)
305
+ if d.get('ok'):
306
+ print(d.get('result', {}).get('message_id', ''))
307
+ else:
308
+ sys.exit(1)
309
+ except:
310
+ sys.exit(1)
311
+ " 2>/dev/null || true)"
312
+
313
+ if [[ -n "$sent_msg_id" ]]; then
314
+ echo "[tq-message] sent via Telegram (msg_id=$sent_msg_id)"
315
+ # Track outgoing message ID in registry for reply threading
316
+ if [[ -n "$sent_msg_id" ]] && command -v tq-converse &>/dev/null; then
317
+ # Try to detect which slug this message belongs to
318
+ local slug_file="$HOME/.tq/conversations/latest-reply-slug"
319
+ if [[ -f "$slug_file" ]]; then
320
+ local slug
321
+ slug="$(cat "$slug_file")"
322
+ tq-converse track-msg "$slug" "$sent_msg_id" 2>/dev/null || true
323
+ rm -f "$slug_file"
324
+ fi
325
+ fi
284
326
  else
285
327
  echo "[tq-message] Telegram error: $response" >&2
286
328
  exit 1
@@ -290,29 +332,56 @@ send_telegram() {
290
332
  deliver() {
291
333
  local service="$1"
292
334
  local msg="$2"
335
+ local reply_to="${3:-}"
293
336
  case "$service" in
294
- telegram) send_telegram "$TG_TOKEN" "$TG_CHAT" "$msg" ;;
337
+ telegram) send_telegram "$TG_TOKEN" "$TG_CHAT" "$msg" "$reply_to" ;;
295
338
  *) echo "[tq-message] unknown service: $service" >&2; exit 1 ;;
296
339
  esac
297
340
  }
298
341
 
299
- # Summary mode with a live session: ask Claude to generate and send
342
+ # Summary mode with a live session: ask Claude to write summary via slash command
300
343
  if [[ "$CONTENT" == "summary" && -n "$SESSION" && -z "$MESSAGE_TEXT" ]]; then
301
344
  if tmux has-session -t "$SESSION" 2>/dev/null; then
302
- # Pass hash and queue path as arguments to the slash command
345
+ HANDSHAKE_FILE="/tmp/tq-summary-${TASK_HASH}.txt"
346
+ rm -f "$HANDSHAKE_FILE"
347
+
348
+ # Tell Claude to generate summary (slash command calls tq-message --message which writes handshake file + delivers)
303
349
  tmux send-keys -t "$SESSION" "/tq-message ${TASK_HASH} ${QUEUE_FILE}" Enter
304
- # Give Claude time to summarize and call back tq-message --message
305
- sleep 45
306
- # Close the session
307
- tmux send-keys -t "$SESSION" "" Enter
308
- exit 0
350
+
351
+ # Poll for the handshake file (max 90 seconds, check every 3s)
352
+ WAITED=0
353
+ while [[ ! -f "$HANDSHAKE_FILE" && "$WAITED" -lt 90 ]]; do
354
+ sleep 3
355
+ WAITED=$(( WAITED + 3 ))
356
+ done
357
+
358
+ if [[ -f "$HANDSHAKE_FILE" ]]; then
359
+ # Slash command already delivered the message — just clean up
360
+ rm -f "$HANDSHAKE_FILE"
361
+ exit 0
362
+ else
363
+ # Timed out — fall back to details
364
+ MESSAGE_TEXT="$(build_message "details" "$TASK_HASH" "$STATE_FILE" "$PROMPT_FILE" "")"
365
+ fi
309
366
  else
310
367
  # Session gone — fall back to details
311
368
  MESSAGE_TEXT="$(build_message "details" "$TASK_HASH" "$STATE_FILE" "$PROMPT_FILE" "")"
312
369
  fi
313
370
  fi
314
371
 
372
+ # Write handshake file for summary polling (if applicable)
373
+ if [[ -n "$MESSAGE_TEXT" && -n "$TASK_HASH" ]]; then
374
+ echo "$MESSAGE_TEXT" > "/tmp/tq-summary-${TASK_HASH}.txt"
375
+ fi
376
+
315
377
  # Deliver message if we have one
316
378
  if [[ -n "$MESSAGE_TEXT" ]]; then
317
- deliver "$SERVICE" "$MESSAGE_TEXT"
379
+ # If no explicit reply-to, check for latest-msg-id (conversation mode)
380
+ if [[ -z "$REPLY_TO_MSG_ID" ]]; then
381
+ LATEST_MSG_FILE="$HOME/.tq/conversations/latest-msg-id"
382
+ if [[ -f "$LATEST_MSG_FILE" ]]; then
383
+ REPLY_TO_MSG_ID="$(cat "$LATEST_MSG_FILE")"
384
+ fi
385
+ fi
386
+ deliver "$SERVICE" "$MESSAGE_TEXT" "$REPLY_TO_MSG_ID"
318
387
  fi
package/scripts/tq-setup CHANGED
@@ -32,7 +32,7 @@ read -r _IGNORED
32
32
 
33
33
  UPDATES="$(curl -s "https://api.telegram.org/bot${BOT_TOKEN}/getUpdates?offset=0&limit=10&timeout=0" 2>&1)"
34
34
 
35
- DISCOVER_SCRIPT=$(mktemp /tmp/tq-setup-discover-XXXXXX.py)
35
+ DISCOVER_SCRIPT=$(mktemp /tmp/tq-setup-discover-XXXXXX)
36
36
  trap 'rm -f "$DISCOVER_SCRIPT"' EXIT
37
37
 
38
38
  cat > "$DISCOVER_SCRIPT" <<'PYEOF'