crewly 1.20.296 → 1.20.297

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.
Files changed (82) hide show
  1. package/config/skills/agent/core/app-data/SKILL.md +13 -1
  2. package/config/skills/agent/core/app-data/execute.sh +60 -6
  3. package/config/skills/agent/core/app-data/execute.test.sh +42 -0
  4. package/config/skills/agent/core/docs-comment/SKILL.md +49 -4
  5. package/config/skills/agent/core/docs-comment/execute.sh +38 -2
  6. package/config/skills/agent/core/docs-comment/execute.test.sh +28 -0
  7. package/dist/backend/backend/src/constants.d.ts +27 -0
  8. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  9. package/dist/backend/backend/src/constants.js +27 -0
  10. package/dist/backend/backend/src/constants.js.map +1 -1
  11. package/dist/backend/backend/src/controllers/apps/apps.controller.d.ts +2 -0
  12. package/dist/backend/backend/src/controllers/apps/apps.controller.d.ts.map +1 -1
  13. package/dist/backend/backend/src/controllers/apps/apps.controller.js +2 -0
  14. package/dist/backend/backend/src/controllers/apps/apps.controller.js.map +1 -1
  15. package/dist/backend/backend/src/controllers/apps/apps.routes.d.ts +1 -0
  16. package/dist/backend/backend/src/controllers/apps/apps.routes.d.ts.map +1 -1
  17. package/dist/backend/backend/src/controllers/apps/apps.routes.js +3 -1
  18. package/dist/backend/backend/src/controllers/apps/apps.routes.js.map +1 -1
  19. package/dist/backend/backend/src/controllers/browser/browser.controller.d.ts +20 -0
  20. package/dist/backend/backend/src/controllers/browser/browser.controller.d.ts.map +1 -1
  21. package/dist/backend/backend/src/controllers/browser/browser.controller.js +52 -9
  22. package/dist/backend/backend/src/controllers/browser/browser.controller.js.map +1 -1
  23. package/dist/backend/backend/src/controllers/google/google.controller.d.ts +31 -0
  24. package/dist/backend/backend/src/controllers/google/google.controller.d.ts.map +1 -1
  25. package/dist/backend/backend/src/controllers/google/google.controller.js +198 -5
  26. package/dist/backend/backend/src/controllers/google/google.controller.js.map +1 -1
  27. package/dist/backend/backend/src/controllers/google/google.routes.d.ts +4 -0
  28. package/dist/backend/backend/src/controllers/google/google.routes.d.ts.map +1 -1
  29. package/dist/backend/backend/src/controllers/google/google.routes.js +9 -1
  30. package/dist/backend/backend/src/controllers/google/google.routes.js.map +1 -1
  31. package/dist/backend/backend/src/index.d.ts.map +1 -1
  32. package/dist/backend/backend/src/index.js +5 -0
  33. package/dist/backend/backend/src/index.js.map +1 -1
  34. package/dist/backend/backend/src/services/agent/agent-registration.service.d.ts.map +1 -1
  35. package/dist/backend/backend/src/services/agent/agent-registration.service.js +4 -2
  36. package/dist/backend/backend/src/services/agent/agent-registration.service.js.map +1 -1
  37. package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.js +1 -1
  38. package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.js.map +1 -1
  39. package/dist/backend/backend/src/services/apps/app-wake-message.d.ts +33 -0
  40. package/dist/backend/backend/src/services/apps/app-wake-message.d.ts.map +1 -1
  41. package/dist/backend/backend/src/services/apps/app-wake-message.js +48 -0
  42. package/dist/backend/backend/src/services/apps/app-wake-message.js.map +1 -1
  43. package/dist/backend/backend/src/services/apps/apps.service.d.ts +23 -0
  44. package/dist/backend/backend/src/services/apps/apps.service.d.ts.map +1 -1
  45. package/dist/backend/backend/src/services/apps/apps.service.js +57 -6
  46. package/dist/backend/backend/src/services/apps/apps.service.js.map +1 -1
  47. package/dist/backend/backend/src/services/google/docs-comment-gate.d.ts +166 -0
  48. package/dist/backend/backend/src/services/google/docs-comment-gate.d.ts.map +1 -0
  49. package/dist/backend/backend/src/services/google/docs-comment-gate.js +176 -0
  50. package/dist/backend/backend/src/services/google/docs-comment-gate.js.map +1 -0
  51. package/dist/backend/backend/src/services/google/docs-comments.service.d.ts.map +1 -1
  52. package/dist/backend/backend/src/services/google/docs-comments.service.js +2 -2
  53. package/dist/backend/backend/src/services/google/docs-comments.service.js.map +1 -1
  54. package/dist/backend/backend/src/services/google/google-api.client.d.ts +15 -0
  55. package/dist/backend/backend/src/services/google/google-api.client.d.ts.map +1 -1
  56. package/dist/backend/backend/src/services/google/google-api.client.js +16 -2
  57. package/dist/backend/backend/src/services/google/google-api.client.js.map +1 -1
  58. package/dist/backend/backend/src/services/google/google-reauth.wiring.js +1 -1
  59. package/dist/backend/backend/src/services/google/google-reauth.wiring.js.map +1 -1
  60. package/dist/backend/backend/src/services/google/google-workspace-token.service.d.ts +12 -0
  61. package/dist/backend/backend/src/services/google/google-workspace-token.service.d.ts.map +1 -1
  62. package/dist/backend/backend/src/services/google/google-workspace-token.service.js +18 -0
  63. package/dist/backend/backend/src/services/google/google-workspace-token.service.js.map +1 -1
  64. package/dist/backend/backend/src/services/worktree/runaway-file-watcher.d.ts +131 -0
  65. package/dist/backend/backend/src/services/worktree/runaway-file-watcher.d.ts.map +1 -0
  66. package/dist/backend/backend/src/services/worktree/runaway-file-watcher.js +251 -0
  67. package/dist/backend/backend/src/services/worktree/runaway-file-watcher.js.map +1 -0
  68. package/dist/backend/backend/src/services/worktree/worktree-janitor.service.d.ts +30 -0
  69. package/dist/backend/backend/src/services/worktree/worktree-janitor.service.d.ts.map +1 -1
  70. package/dist/backend/backend/src/services/worktree/worktree-janitor.service.js +82 -0
  71. package/dist/backend/backend/src/services/worktree/worktree-janitor.service.js.map +1 -1
  72. package/dist/backend/build-info.json +2 -2
  73. package/dist/cli/backend/src/constants.d.ts +27 -0
  74. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  75. package/dist/cli/backend/src/constants.js +27 -0
  76. package/dist/cli/backend/src/constants.js.map +1 -1
  77. package/dist/cli/backend/src/services/agent/agent-registration.service.d.ts.map +1 -1
  78. package/dist/cli/backend/src/services/agent/agent-registration.service.js +4 -2
  79. package/dist/cli/backend/src/services/agent/agent-registration.service.js.map +1 -1
  80. package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js +1 -1
  81. package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js.map +1 -1
  82. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: App Data
3
3
  description: Read and write the data of a Crewly App you published (the same collections the app's page sees through crewly.db) — list, get, set, update, add, delete. Use it to fill an app with content, act on what the owner entered, or answer an [APP CHANGES] message.
4
- version: 1.5.0
4
+ version: 1.6.0
5
5
  category: productivity
6
6
  skillType: claude-skill
7
7
  assignableRoles:
@@ -44,6 +44,7 @@ bash execute.sh --app 28au74d9cj --set meta settings --data '{"title":"Weekly sh
44
44
  bash execute.sh --app 28au74d9cj --update items 1kx2-ab --data '{"done":true}' --if-rev 3
45
45
  bash execute.sh --app 28au74d9cj --add items --data '{"name":"eggs","done":false}'
46
46
  bash execute.sh --app 28au74d9cj --delete items 1kx2-ab
47
+ bash execute.sh --app 28au74d9cj --get-field items 1kx2-ab --field /photo --out ./photo.png --decode # full value of a cut field
47
48
  bash execute.sh --app 28au74d9cj --request-access --reason "write the morning briefing" # ask the owner, see "Collaborators"
48
49
  bash execute.sh --app 28au74d9cj --collaborators # who the owner let in
49
50
  bash execute.sh --app 28au74d9cj --owner # who the app's comments go to
@@ -200,3 +201,14 @@ write private information there.
200
201
 
201
202
  Collection names: `[A-Za-z0-9_-]{1,64}`. Doc ids: `[A-Za-z0-9_.:-]{1,128}`.
202
203
  Documents are JSON objects up to 256 KB.
204
+
205
+ ## Long values (over 65,536 characters)
206
+
207
+ `--get` and `--list` cut any string over 65,536 characters (a base64 photo, a
208
+ long document) so it cannot flood your terminal. A cut is never silent: the
209
+ answer carries `truncated: [{"field":"/photo","chars":123456}]` (in a list,
210
+ each entry also has the doc `id`). Do not parse a flagged value as if it were
211
+ whole. Fetch it with `--get-field <collection> <docId> --field <pointer> --out
212
+ <file>`: the full value goes to a file inside your project (nothing is printed
213
+ but `{file, bytes}`). Add `--decode` for base64 / data-URL values to get the
214
+ real binary (the image itself).
@@ -15,6 +15,7 @@ print_usage() {
15
15
  Usage:
16
16
  bash execute.sh --app <appId> --list <collection> [--limit 100] [--after <docId>]
17
17
  bash execute.sh --app <appId> --get <collection> <docId>
18
+ bash execute.sh --app <appId> --get-field <collection> <docId> --field </json/pointer> --out <file> [--decode]
18
19
  bash execute.sh --app <appId> --set <collection> <docId> --data '{"done":true}'
19
20
  bash execute.sh --app <appId> --update <collection> <docId> --data '{"done":true}' [--if-rev 4]
20
21
  bash execute.sh --app <appId> --add <collection> --data '{"name":"milk"}'
@@ -32,6 +33,10 @@ Options:
32
33
  --if-rev Only update when the doc is still at this rev (409 conflict otherwise)
33
34
  --limit Page size for --list (1-500, default 100)
34
35
  --after Continue a --list after this doc id (the previous page's "next")
36
+ --get-field Save the FULL value of one field to a file (reads cut strings over 65,536 characters and list them in "truncated"); prints only {file, bytes}
37
+ --field JSON pointer into the doc's data, as listed in "truncated" (e.g. /photo, /items/0/image)
38
+ --out File to write for --get-field: inside your project directory, not an existing symlink
39
+ --decode With --get-field: base64-decode the value (a data:...;base64, prefix is dropped) so a photo is written as the real image
35
40
  --upload Put a file (an image for a post) into the app and print its url; works for the owner's agents and for collaborators. Regular, non-symlink file inside your project directory, up to 10 MB (the app's own limit may be smaller)
36
41
  --name File name to show for --upload (default: the file's name)
37
42
  --request-access Ask the owner to let your team (default) or only you work in an app another team published; a card goes to the owner, nothing is granted until they tap Allow
@@ -51,10 +56,20 @@ fail_from() {
51
56
  exit 1
52
57
  }
53
58
 
59
+ # Run one API call. stdout (the whole response body, however many lines it
60
+ # spans) is kept apart from stderr (the missing-session warning, or the JSON
61
+ # error object api_call prints on failure) so neither pollutes the other.
54
62
  call() {
55
- local out
56
- out=$(api_call "$@" 2>&1) || { fail_from "$(printf '%s\n' "$out" | tail -n 1)"; }
57
- printf '%s\n' "$out" | tail -n 1
63
+ local out err errfile rc=0
64
+ errfile=$(mktemp "${TMPDIR:-/tmp}/app-data-err.XXXXXX") || errfile=/dev/null
65
+ out=$(api_call "$@" 2>"$errfile") || rc=$?
66
+ err=$(cat "$errfile" 2>/dev/null || true)
67
+ [ "$errfile" = /dev/null ] || rm -f "$errfile"
68
+ if [ "$rc" -ne 0 ]; then
69
+ # The error object is the last non-empty stderr line (warnings come first).
70
+ fail_from "$(printf '%s\n' "$err" | awk 'NF{l=$0} END{print l}')"
71
+ fi
72
+ printf '%s\n' "$out"
58
73
  }
59
74
 
60
75
  uri() { jq -rn --arg v "$1" '$v|@uri'; }
@@ -107,13 +122,35 @@ content_type_for() {
107
122
  esac
108
123
  }
109
124
 
110
- APP=""; OP=""; UPLOAD=""; NAME=""; COLL=""; DOC=""; DATA=""; IF_REV=""; LIMIT=""; AFTER=""; SCOPE=""; REASON=""; SET_OWNER=""; WHO=""
125
+ # --out for --get-field: a new regular file inside the project directory
126
+ # (CREWLY_PROJECT_PATH, else the working directory), never under Crewly's home,
127
+ # never through a symlink. Prints the absolute target path.
128
+ resolve_out_path() {
129
+ local given="$1" dir base project real_dir
130
+ dir="$(dirname "$given")"; base="$(basename "$given")"
131
+ [ -d "$dir" ] || error_exit "the directory of --out does not exist: $dir"
132
+ real_dir="$(cd "$dir" && pwd -P)"
133
+ project="$(cd "${CREWLY_PROJECT_PATH:-$PWD}" && pwd -P)" || error_exit "cannot resolve the project directory"
134
+ case "$real_dir/" in "$project"/*) ;; *) error_exit "--out must be inside your project directory ($project)" ;; esac
135
+ case "$real_dir/" in "${CREWLY_HOME:-$HOME/.crewly}"/*|"$HOME/.crewly"/*) error_exit "--out is inside Crewly's home directory" ;; esac
136
+ [ ! -L "$real_dir/$base" ] || error_exit "--out is a symbolic link; pass a real file path"
137
+ [ ! -d "$real_dir/$base" ] || error_exit "--out is a directory"
138
+ printf '%s/%s' "$real_dir" "$base"
139
+ }
140
+
141
+ APP=""; OP=""; UPLOAD=""; NAME=""; COLL=""; DOC=""; DATA=""; IF_REV=""; LIMIT=""; AFTER=""; SCOPE=""; REASON=""; SET_OWNER=""; WHO=""; FIELD=""; OUT_FILE=""; DECODE=""
111
142
  while [[ $# -gt 0 ]]; do
112
143
  case "$1" in
113
144
  --app) [ $# -ge 2 ] || error_exit "--app requires a value"; APP="$2"; shift 2 ;;
114
145
  --list|--add)
115
146
  [ $# -ge 2 ] || error_exit "$1 requires a collection"
116
147
  OP="${1#--}"; COLL="$2"; shift 2 ;;
148
+ --get-field)
149
+ [ $# -ge 3 ] || error_exit "--get-field requires a collection and a doc id"
150
+ OP="get-field"; COLL="$2"; DOC="$3"; shift 3 ;;
151
+ --field) [ $# -ge 2 ] || error_exit "--field requires a JSON pointer"; FIELD="$2"; shift 2 ;;
152
+ --out) [ $# -ge 2 ] || error_exit "--out requires a file path"; OUT_FILE="$2"; shift 2 ;;
153
+ --decode) DECODE=1; shift ;;
117
154
  --get|--set|--update|--delete)
118
155
  [ $# -ge 3 ] || error_exit "$1 requires a collection and a doc id"
119
156
  OP="${1#--}"; COLL="$2"; DOC="$3"; shift 3 ;;
@@ -191,11 +228,28 @@ case "$OP" in
191
228
  [ -n "$LIMIT" ] && Q="limit=$(uri "$LIMIT")"
192
229
  [ -n "$AFTER" ] && Q="${Q:+$Q&}after=$(uri "$AFTER")"
193
230
  RESPONSE=$(call GET "${BASE}${Q:+?$Q}") || { printf '%s\n' "$RESPONSE"; exit 1; }
194
- printf '%s' "$RESPONSE" | jq -c '{success: true, docs: [.data.docs[]? | {id, data, rev, updatedAt, updatedBy: (.updatedBy.kind // null)} + (if .updatedBy.kind == "agent" then {by: .updatedBy.id} else {} end)], next: .data.next}'
231
+ printf '%s' "$RESPONSE" | jq -c '{success: true, docs: [.data.docs[]? | {id, data, rev, updatedAt, updatedBy: (.updatedBy.kind // null)} + (if .updatedBy.kind == "agent" then {by: .updatedBy.id} else {} end)], next: .data.next} + (if .data.truncated then {truncated: .data.truncated, truncatedHint: .data.truncatedHint} else {} end)'
232
+ ;;
233
+ get-field)
234
+ [ -n "$FIELD" ] || error_exit "--field is required for --get-field (a JSON pointer such as /photo)"
235
+ [ -n "$OUT_FILE" ] || error_exit "--out is required for --get-field"
236
+ TARGET="$(resolve_out_path "$OUT_FILE")" || exit 1
237
+ RESPONSE=$(CREWLY_SKILL_FULL_OUTPUT=1 call GET "${BASE}/$(uri "$DOC")/field?path=$(uri "$FIELD")") || { printf '%s\n' "$RESPONSE"; exit 1; }
238
+ printf '%s' "$RESPONSE" | jq -e '.data.type == "string"' >/dev/null 2>&1 || error_exit "that field is not a string; read it with --get"
239
+ TMP_OUT="$(mktemp "${TARGET}.XXXXXX")" || error_exit "cannot write next to $OUT_FILE"
240
+ printf '%s' "$RESPONSE" | jq -j '.data.value' > "$TMP_OUT"
241
+ if [ -n "$DECODE" ]; then
242
+ sed -E '1s#^data:[^,]*;base64,##' "$TMP_OUT" | tr -d '\n\r ' > "${TMP_OUT}.b64"
243
+ { base64 -d < "${TMP_OUT}.b64" > "${TMP_OUT}.bin" 2>/dev/null || base64 -D < "${TMP_OUT}.b64" > "${TMP_OUT}.bin" 2>/dev/null; } \
244
+ || { rm -f "$TMP_OUT" "${TMP_OUT}.b64" "${TMP_OUT}.bin"; error_exit "the value is not valid base64"; }
245
+ mv "${TMP_OUT}.bin" "$TMP_OUT"; rm -f "${TMP_OUT}.b64"
246
+ fi
247
+ mv "$TMP_OUT" "$TARGET"
248
+ jq -cn --arg f "$TARGET" --argjson b "$(wc -c < "$TARGET" | tr -d ' ')" --argjson c "$(printf '%s' "$RESPONSE" | jq '.data.chars')" '{success: true, file: $f, bytes: $b, chars: $c}'
195
249
  ;;
196
250
  get)
197
251
  RESPONSE=$(call GET "${BASE}/$(uri "$DOC")") || { printf '%s\n' "$RESPONSE"; exit 1; }
198
- printf '%s' "$RESPONSE" | jq -c '{success: true, id: .data.id, data: .data.data, rev: .data.rev, updatedAt: .data.updatedAt}'
252
+ printf '%s' "$RESPONSE" | jq -c '{success: true, id: .data.id, data: .data.data, rev: .data.rev, updatedAt: .data.updatedAt} + (if .data.truncated then {truncated: .data.truncated, truncatedHint: .data.truncatedHint} else {} end)'
199
253
  ;;
200
254
  set)
201
255
  need_data
@@ -24,6 +24,18 @@ class H(BaseHTTPRequestHandler):
24
24
  def send(code, obj):
25
25
  self.send_response(code); self.send_header('Content-Type', 'application/json'); self.end_headers()
26
26
  self.wfile.write(json.dumps(obj).encode())
27
+ if self.path.endswith('/pretty'):
28
+ self.send_response(200); self.send_header('Content-Type', 'application/json'); self.end_headers()
29
+ return self.wfile.write(json.dumps({'success': True, 'data': {'id': 'pretty', 'data': {'text': 'line1\nline2'}, 'rev': 3, 'updatedAt': 't'}}, indent=2).encode())
30
+ if '/field' in self.path:
31
+ from urllib.parse import urlparse, parse_qs
32
+ q = parse_qs(urlparse(self.path).query).get('path', [''])[0]
33
+ if q == '/photo':
34
+ return send(200, {'success': True, 'data': {'field': q, 'type': 'string', 'chars': 38, 'value': 'data:image/png;base64,aGVsbG8gd29ybGQ='}})
35
+ if q == '/n':
36
+ return send(200, {'success': True, 'data': {'field': q, 'type': 'number', 'value': 1}})
37
+ if self.path.endswith('/trunc'):
38
+ return send(200, {'success': True, 'data': {'id': 'trunc', 'data': {'photo': 'xx'}, 'rev': 1, 'updatedAt': 't', 'truncated': [{'field': '/photo', 'chars': 70000}], 'truncatedHint': 'cut'}})
27
39
  if self.path.endswith('/missing'):
28
40
  return send(404, {'success': False, 'error': 'not_found', 'message': 'Document not found.', 'hint': 'No such app, document or version for this account.'})
29
41
  if self.path.endswith('/collaborators') and method == 'GET':
@@ -89,6 +101,15 @@ check "list: path" "$(jq -r .path "$STUB_LOG")" "/api/apps/$A/data/items?limit=5
89
101
  OUT=$(run --app $A --get items milk)
90
102
  check "get" "$OUT" '{"success":true,"id":"milk","data":{"name":"milk","done":false},"rev":2,"updatedAt":"t"}'
91
103
 
104
+ # A multi-line (pretty-printed) response body must survive whole (#1122).
105
+ OUT=$(run --app $A --get items pretty)
106
+ check "get: multi-line body kept whole" "$OUT" '{"success":true,"id":"pretty","data":{"text":"line1\nline2"},"rev":3,"updatedAt":"t"}'
107
+ # The missing-session warning goes to stderr and must not break the JSON (#1122).
108
+ OUT=$(env "${ENVS[@]}" CREWLY_SESSION_NAME= bash "$EXEC" --app $A --get items pretty 2>/dev/null)
109
+ check "get: stderr warning does not pollute stdout" "$(printf '%s' "$OUT" | jq -r .id)" "pretty"
110
+ OUT=$(env "${ENVS[@]}" CREWLY_SESSION_NAME= bash "$EXEC" --app $A --get items missing 2>/dev/null; true)
111
+ check "error with warning: reason still parsed" "$(printf '%s' "$OUT" | jq -r .reason)" "not_found"
112
+
92
113
  OUT=$(run --app $A --get items missing; true)
93
114
  check "get missing → not_found" "$(printf '%s' "$OUT" | jq -c '{success, status, reason}')" '{"success":false,"status":404,"reason":"not_found"}'
94
115
 
@@ -195,5 +216,26 @@ check "upload: 2xx without a url fails, not success with nulls" "$(printf '%s' "
195
216
  OUT=$(run_err --app $A --upload || true)
196
217
  check "upload needs a path" "$(printf '%s' "$OUT" | grep -c 'requires a file path')" "1"
197
218
 
219
+ # --get / --list carry the truncation flag through (#1145).
220
+ OUT=$(run --app $A --get items trunc)
221
+ check "get: truncation flag passed through" "$(printf '%s' "$OUT" | jq -c '.truncated')" '[{"field":"/photo","chars":70000}]'
222
+
223
+ # --get-field --out writes the full value to a file and prints no content (#1145).
224
+ OUT=$(run --app $A --get-field items milk --field /photo --out "$WORK/proj/photo.txt")
225
+ check "get-field: path" "$(jq -r .path "$STUB_LOG")" "/api/apps/$A/data/items/milk/field?path=%2Fphoto"
226
+ check "get-field: file holds the raw value" "$(cat "$WORK/proj/photo.txt")" 'data:image/png;base64,aGVsbG8gd29ybGQ='
227
+ check "get-field: prints a receipt, not the value" "$(printf '%s' "$OUT" | jq -c '{success, bytes, chars}')" '{"success":true,"bytes":38,"chars":38}'
228
+ run --app $A --get-field items milk --field /photo --out "$WORK/proj/photo.bin" --decode >/dev/null
229
+ check "get-field --decode: data URL decoded to bytes" "$(cat "$WORK/proj/photo.bin")" 'hello world'
230
+ OUT=$(run_err --app $A --get-field items milk --field /photo --out "$WORK/outside-photo.txt" || true)
231
+ check "get-field: --out outside the project refused" "$(printf '%s' "$OUT" | grep -c 'inside your project')" "1"
232
+ ln -s "$WORK/outside.json" "$WORK/proj/out-link.txt"
233
+ OUT=$(run_err --app $A --get-field items milk --field /photo --out "$WORK/proj/out-link.txt" || true)
234
+ check "get-field: symlink --out refused" "$(printf '%s' "$OUT" | grep -c 'symbolic link')" "1"
235
+ OUT=$(run_err --app $A --get-field items milk --field /n --out "$WORK/proj/n.txt" || true)
236
+ check "get-field: non-string field refused" "$(printf '%s' "$OUT" | grep -c 'not a string')" "1"
237
+ OUT=$(run_err --app $A --get-field items milk --out "$WORK/proj/x.txt" || true)
238
+ check "get-field: --field required" "$(printf '%s' "$OUT" | grep -c -- '--field is required')" "1"
239
+
198
240
  echo "app-data: $PASS passed, $FAIL failed"
199
241
  [ "$FAIL" -eq 0 ]
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: Docs Comment
3
- description: Handle feedback left as comments in a Google Doc — list comments and replies, reply, resolve, or add a comment (via the Google Workspace grant held by Crewly Cloud). Writes as the owner's Google account — say what you posted.
4
- version: 1.0.0
3
+ description: Handle feedback left as comments in a Google Doc — list comments and replies; reply, resolve and add are queued for the owner's OK and posted as a batch only after they reply 「发 C<n>」 (via the Google Workspace grant held by Crewly Cloud).
4
+ version: 1.1.0
5
5
  category: productivity
6
6
  skillType: claude-skill
7
7
  assignableRoles:
@@ -41,6 +41,9 @@ bash execute.sh reply --doc 1AbC… --comment AAAB… --text "Updated the intr
41
41
  bash execute.sh resolve --doc 1AbC… --comment AAAB… --text "Done in v2."
42
42
  bash execute.sh resolve --doc 1AbC… --comment AAAB…
43
43
  bash execute.sh add --doc 1AbC… --text "Is this number from Q2?" --quote "grew 40%"
44
+ bash execute.sh pending # what is queued and waiting on the owner
45
+ bash execute.sh send --batch C3 # post a batch AFTER the owner said 「发 C3」
46
+ bash execute.sh discard --batch C3
44
47
  ```
45
48
 
46
49
  `--doc` takes a document id or its docs.google.com URL. `--account` picks a
@@ -56,8 +59,45 @@ connected Google account (see below).
56
59
  with a question if it is unclear. Do not resolve a comment you did not
57
60
  address.
58
61
 
59
- Everything you post appears under the owner's Google name. Tell the owner
60
- what you replied to or resolved.
62
+ ## Posting needs the owner's OK (one OK per batch)
63
+
64
+ Comments and replies on a Google Doc are visible to every collaborator under
65
+ the owner's name, so `reply`, `resolve` and `add` are outbound actions like
66
+ `gmail-send`: **they do not post.** Each call queues the comment and returns
67
+ a batch code:
68
+
69
+ ```json
70
+ {"success":true,"held":true,"posted":false,"batch":"C3","queued":38,"message":"Queued as C3 …"}
71
+ ```
72
+
73
+ All your queued comments share one batch, so work through the whole document
74
+ first, then ask the owner ONCE, in plain words, with what will go out:
75
+
76
+ > "I have 38 replies ready on the Q3 plan doc (mostly "done, see para 2",
77
+ > 4 questions). Reply 「发 C3」 to post them all."
78
+
79
+ The owner answers from their phone with one message, `发 C3` (or `send C3`).
80
+ Only that genuine owner message counts — not a suggestion, a pre-filled line,
81
+ or your own paraphrase. When the `[SLACK…]`/`[CHAT:…]` message arrives, run:
82
+
83
+ ```bash
84
+ bash execute.sh send --batch C3
85
+ ```
86
+
87
+ Rules:
88
+
89
+ - The OK must come AFTER your last queued comment. If you queue one more
90
+ after asking, the earlier OK no longer covers the batch: ask again.
91
+ - `send` posts in order and stops on the first failure; whatever is not
92
+ posted stays queued, so running `send` again never double-posts.
93
+ - A refusal (`needs_owner_confirmation`) means the owner has not said it
94
+ yet. Do not look for another way to post.
95
+ - If the owner wants changes, `discard --batch C3` and queue fresh ones.
96
+ - Queued comments live in this Crewly process; if it restarts before the OK,
97
+ queue them again.
98
+ - `list` is read-only and always open.
99
+
100
+ Tell the owner what you posted once `send` succeeds.
61
101
 
62
102
  ## Output
63
103
 
@@ -71,6 +111,11 @@ Resolved comments are left out unless you pass `--include-resolved`.
71
111
  `truncated: true` means the document has more than 500 comments and only the
72
112
  first 500 were read.
73
113
 
114
+ `reply` / `resolve` / `add` queue the comment (see above) until `send --batch`
115
+ succeeds. A successful `send` returns `{"success":true,"batch":"C3","posted":38,"remaining":0,"results":[…]}`;
116
+ on a mid-batch failure `success` is false with `error: "partial"` and the number still queued.
117
+ When the batch posts, each result has the shape below.
118
+
74
119
  `reply` / `resolve`:
75
120
 
76
121
  ```json
@@ -13,6 +13,9 @@
13
13
  # bash execute.sh reply --doc <id|url> --comment <id> --text "…"
14
14
  # bash execute.sh resolve --doc <id|url> --comment <id> [--text "…"]
15
15
  # bash execute.sh add --doc <id|url> --text "…" [--quote "exact text"]
16
+ # bash execute.sh pending (queued comments awaiting the owner)
17
+ # bash execute.sh send --batch C3 (post a batch the owner approved)
18
+ # bash execute.sh discard --batch C3
16
19
  # bash execute.sh '{"command":"list","doc":"<id>"}'
17
20
  # =============================================================================
18
21
  set -euo pipefail
@@ -26,6 +29,9 @@ Usage:
26
29
  bash execute.sh reply --doc <id|url> --comment <id> --text "…"
27
30
  bash execute.sh resolve --doc <id|url> --comment <id> [--text "…"]
28
31
  bash execute.sh add --doc <id|url> --text "…" [--quote "exact text in the doc"]
32
+ bash execute.sh pending
33
+ bash execute.sh send --batch C3
34
+ bash execute.sh discard --batch C3
29
35
  bash execute.sh '{"command":"reply","doc":"<id>","comment":"<id>","text":"…"}'
30
36
 
31
37
  Options:
@@ -35,6 +41,10 @@ Options:
35
41
  --quote Exact document text the new comment is about (add only;
36
42
  Google Docs may still show it as an unanchored comment)
37
43
  --include-resolved list: include resolved comments too
44
+ --batch send / discard: the batch code a reply / resolve / add returned (e.g. C3)
45
+
46
+ reply, resolve and add do NOT post: they queue the comment for the owner and
47
+ return a batch code. After the owner answers 「发 <code>」, run send --batch.
38
48
  --account Which connected Google account to act as (default: your primary)
39
49
  --help | -h Show this help
40
50
  EOF_USAGE
@@ -66,7 +76,7 @@ elif [[ $# -gt 0 && ${1:0:1} != '-' ]]; then
66
76
  COMMAND="$1"
67
77
  shift
68
78
  fi
69
- DOC=""; COMMENT=""; TEXT=""; TEXT_SET=0; QUOTE=""; INCLUDE_RESOLVED=0; ACCOUNT=""
79
+ DOC=""; COMMENT=""; TEXT=""; TEXT_SET=0; QUOTE=""; INCLUDE_RESOLVED=0; ACCOUNT=""; BATCH=""
70
80
  while [[ $# -gt 0 ]]; do
71
81
  case "$1" in
72
82
  --doc|--id) [ $# -ge 2 ] || error_exit "$1 requires a value"; DOC="$2"; shift 2 ;;
@@ -75,6 +85,7 @@ while [[ $# -gt 0 ]]; do
75
85
  --quote) [ $# -ge 2 ] || error_exit "--quote requires a value"; QUOTE="$2"; shift 2 ;;
76
86
  --include-resolved) INCLUDE_RESOLVED=1; shift ;;
77
87
  --account) [ $# -ge 2 ] || error_exit "--account requires a value"; ACCOUNT="$2"; shift 2 ;;
88
+ --batch) [ $# -ge 2 ] || error_exit "--batch requires a value"; BATCH="$2"; shift 2 ;;
78
89
  --help|-h) print_usage; exit 0 ;;
79
90
  *) error_exit "Unknown option: $1" ;;
80
91
  esac
@@ -88,13 +99,35 @@ if [ -n "$INPUT_JSON" ]; then
88
99
  [ -z "$QUOTE" ] && QUOTE=$(printf '%s' "$INPUT" | jq -r '.quote // empty')
89
100
  [ "$INCLUDE_RESOLVED" -eq 0 ] && [ "$(printf '%s' "$INPUT" | jq -r '.includeResolved // false')" = "true" ] && INCLUDE_RESOLVED=1
90
101
  [ -z "$ACCOUNT" ] && ACCOUNT=$(printf '%s' "$INPUT" | jq -r '.account // empty')
102
+ [ -z "$BATCH" ] && BATCH=$(printf '%s' "$INPUT" | jq -r '.batch // empty')
91
103
  fi
92
104
  # Route the call at one connected Google account; unset means the default.
93
105
  [ -n "$ACCOUNT" ] && export CREWLY_GOOGLE_ACCOUNT="$ACCOUNT"
94
106
  # Accept a full docs.google.com URL too.
95
107
  DOC=$(printf '%s' "$DOC" | sed -E 's#.*/document/d/([^/?]+).*#\1#')
96
108
 
97
- [ -n "$COMMAND" ] || error_exit "a command is required: list, reply, resolve or add (see --help)"
109
+ [ -n "$COMMAND" ] || error_exit "a command is required: list, reply, resolve, add, pending, send or discard (see --help)"
110
+ BATCHES="/google/doc-comment-batches"
111
+ # Comment writes are outbound: an agent's are queued, not posted (HTTP 202).
112
+ # Print the queue answer instead of a post result.
113
+ held_or() {
114
+ if printf '%s' "$1" | jq -e '.data.held == true' >/dev/null 2>&1; then
115
+ printf '%s' "$1" | jq -c '{success: true, held: true, posted: false, batch: .data.batch, queued: .data.queued, message: .data.message}'
116
+ return 0
117
+ fi
118
+ return 1
119
+ }
120
+ case "$COMMAND" in
121
+ pending)
122
+ RESPONSE=$(call GET "$BATCHES") || { printf '%s\n' "$RESPONSE"; exit 1; }
123
+ printf '%s' "$RESPONSE" | jq -c '{success: (.success // false), batches: [.data.batches[] | {batch: .code, agent: .agentSession, queued: (.items | length), items: [.items[] | {action, docId, commentId, text, quote}]}]}'
124
+ exit 0 ;;
125
+ send|discard)
126
+ require_param "batch (--batch)" "$BATCH"
127
+ RESPONSE=$(call POST "${BATCHES}/$(uri "$BATCH")/${COMMAND}" '{}') || { printf '%s\n' "$RESPONSE"; exit 1; }
128
+ printf '%s' "$RESPONSE" | jq -c '{success: (.success // false)} + .data'
129
+ exit 0 ;;
130
+ esac
98
131
  require_param "doc (--doc)" "$DOC"
99
132
  BASE="/google/docs/$(uri "$DOC")/comments"
100
133
 
@@ -111,18 +144,21 @@ case "$COMMAND" in
111
144
  require_param "text (--text)" "$TEXT"
112
145
  BODY=$(jq -cn --arg text "$TEXT" '{text: $text}')
113
146
  RESPONSE=$(call POST "${BASE}/$(uri "$COMMENT")/replies" "$BODY") || { printf '%s\n' "$RESPONSE"; exit 1; }
147
+ held_or "$RESPONSE" && exit 0
114
148
  printf '%s' "$RESPONSE" | jq -c '{success: (.success // false), action: "reply", docId: .data.docId, commentId: .data.commentId, replyId: .data.id, content: .data.content}'
115
149
  ;;
116
150
  resolve)
117
151
  require_param "comment (--comment)" "$COMMENT"
118
152
  BODY=$(jq -cn --arg text "$TEXT" 'if $text != "" then {text: $text} else {} end')
119
153
  RESPONSE=$(call POST "${BASE}/$(uri "$COMMENT")/resolve" "$BODY") || { printf '%s\n' "$RESPONSE"; exit 1; }
154
+ held_or "$RESPONSE" && exit 0
120
155
  printf '%s' "$RESPONSE" | jq -c '{success: (.success // false), action: "resolve", docId: .data.docId, commentId: .data.commentId, replyId: .data.id, content: (.data.content // ""), resolved: true}'
121
156
  ;;
122
157
  add)
123
158
  require_param "text (--text)" "$TEXT"
124
159
  BODY=$(jq -cn --arg text "$TEXT" --arg quote "$QUOTE" '{text: $text} + (if $quote != "" then {quote: $quote} else {} end)')
125
160
  RESPONSE=$(call POST "${BASE}" "$BODY") || { printf '%s\n' "$RESPONSE"; exit 1; }
161
+ held_or "$RESPONSE" && exit 0
126
162
  printf '%s' "$RESPONSE" | jq -c '{success: (.success // false), action: "add", docId: .data.docId, commentId: .data.id, content: .data.content} + (if .data.quote then {quote: .data.quote} else {} end)'
127
163
  ;;
128
164
  *)
@@ -30,6 +30,14 @@ class H(BaseHTTPRequestHandler):
30
30
  return self._send(403, {'success': False, 'error': 'reauth_required',
31
31
  'message': 'Replying to or adding comments on this document needs Google Drive edit access, which this Google account has not granted yet.',
32
32
  'hint': 'Ask the owner to reconnect Google Drive: run the google-connect skill with --product drive.'})
33
+ if '/docs/held/' in self.path:
34
+ return self._send(202, {'success': True, 'data': {'held': True, 'batch': 'C3', 'queued': 2, 'message': 'Queued as C3'}})
35
+ if self.path.startswith('/api/google/doc-comment-batches'):
36
+ if method == 'GET':
37
+ return self._send(200, {'success': True, 'data': {'batches': [{'code': 'C3', 'agentSession': 'ella', 'items': [{'action': 'reply', 'docId': 'd1', 'commentId': 'c1', 'text': 'Fixed'}]}]}})
38
+ if '/C9/' in self.path:
39
+ return self._send(403, {'success': False, 'error': 'needs_owner_confirmation', 'message': 'The owner has not confirmed batch C9'})
40
+ return self._send(200, {'success': True, 'data': {'batch': 'C3', 'posted': 2, 'remaining': 0}})
33
41
  if method == 'GET':
34
42
  return self._send(200, {'success': True, 'data': {'docId': 'd1', 'comments': [COMMENT], 'truncated': False}})
35
43
  if self.path.endswith('/replies'):
@@ -93,6 +101,26 @@ check "reauth: exit code" "$CODE" "1"
93
101
  check "reauth: reason" "$(printf '%s' "$OUT" | jq -r '.reason')" "reauth_required"
94
102
  check "reauth: hint names the one-tap path" "$(printf '%s' "$OUT" | jq -r '.hint' | grep -c 'google-connect skill with --product drive')" "1"
95
103
 
104
+ # a held comment is reported as queued, never as posted
105
+ for ACT in "reply --doc held --comment c1 --text x" "resolve --doc held --comment c1" "add --doc held --text x"; do
106
+ OUT=$(run $ACT)
107
+ check "held (${ACT%% *}): output" "$OUT" '{"success":true,"held":true,"posted":false,"batch":"C3","queued":2,"message":"Queued as C3"}'
108
+ done
109
+
110
+ # pending / send / discard
111
+ OUT=$(run pending)
112
+ check "pending: request" "$(req)" 'GET /api/google/doc-comment-batches null'
113
+ check "pending: output" "$OUT" '{"success":true,"batches":[{"batch":"C3","agent":"ella","queued":1,"items":[{"action":"reply","docId":"d1","commentId":"c1","text":"Fixed","quote":null}]}]}'
114
+ OUT=$(run send --batch C3)
115
+ check "send: request" "$(req)" 'POST /api/google/doc-comment-batches/C3/send {}'
116
+ check "send: output" "$OUT" '{"success":true,"batch":"C3","posted":2,"remaining":0}'
117
+ run discard --batch C3 >/dev/null
118
+ check "discard: request" "$(req)" 'POST /api/google/doc-comment-batches/C3/discard {}'
119
+ OUT=$(run send --batch C9); CODE=$?
120
+ check "send refused: exit code" "$CODE" "1"
121
+ check "send refused: reason" "$(printf '%s' "$OUT" | jq -r '.reason')" "needs_owner_confirmation"
122
+ check "send needs --batch" "$(run_err send | grep -c 'batch')" "1"
123
+
96
124
  # argument validation (no request sent)
97
125
  check "no command" "$(run_err --doc d1 | grep -c 'command is required')" "1"
98
126
  check "unknown command" "$(run_err delete --doc d1 | grep -c 'Unknown command')" "1"
@@ -829,6 +829,13 @@ export declare const BROWSER_BRIDGE_CONSTANTS: {
829
829
  readonly WS_PATH: "/ws/browser";
830
830
  /** Default timeout for commands sent to Chrome Extension (ms) */
831
831
  readonly COMMAND_TIMEOUT_MS: 30000;
832
+ /**
833
+ * Timeout for screenshot / full-page-screenshot (ms). Capturing a heavy
834
+ * page (Slack web, long feeds) and relaying a multi-MB base64 image takes
835
+ * well beyond the 30 s default; the shorter limit made them look like a
836
+ * missing browser (crewly#1080).
837
+ */
838
+ readonly SCREENSHOT_TIMEOUT_MS: 90000;
832
839
  /**
833
840
  * Per-tab dispatch (1 agent : 1 tab) — see
834
841
  * `.crewly/specs/crewly-in-chrome-per-tab-fix-2026-04-25.md`.
@@ -2821,6 +2828,26 @@ export declare const WORKTREE_JANITOR_CONSTANTS: {
2821
2828
  readonly LOW_DISK_REPORT_ITEMS: 5;
2822
2829
  /** Timeout of one `du -sk` size probe (ms). */
2823
2830
  readonly DU_TIMEOUT_MS: 60000;
2831
+ /** Runaway-file watcher (crewly#1078): how often agent scratch dirs and worktrees are scanned (1 minute). */
2832
+ readonly RUNAWAY_CHECK_INTERVAL_MS: number;
2833
+ /** A file this big in an agent dir is reported to the owner (5 GB); if it is also still growing, its writer is stopped. */
2834
+ readonly RUNAWAY_FILE_BYTES: number;
2835
+ /** A file that grew by this much per minute between two scans is a runaway whatever its size (1 GB/min). */
2836
+ readonly RUNAWAY_GROWTH_BYTES_PER_MIN: number;
2837
+ /** Files smaller than this are never tracked between scans (cheap pre-filter, 256 MB). */
2838
+ readonly RUNAWAY_TRACK_MIN_BYTES: number;
2839
+ /** Bytes of a stopped runaway file kept (the head, so the agent can see what it was writing, 1 MB). */
2840
+ readonly RUNAWAY_KEEP_HEAD_BYTES: number;
2841
+ /** How deep under each watched dir files are looked for. */
2842
+ readonly RUNAWAY_SCAN_DEPTH: 8;
2843
+ /** Cap on directory entries visited per scan. */
2844
+ readonly RUNAWAY_SCAN_MAX_ENTRIES: 200000;
2845
+ /** Wait after SIGTERM before SIGKILL for the writer (ms). */
2846
+ readonly RUNAWAY_KILL_GRACE_MS: 3000;
2847
+ /** Timeout of the `lsof` that finds a file's writers (ms). */
2848
+ readonly RUNAWAY_LSOF_TIMEOUT_MS: 10000;
2849
+ /** One line added to every agent's start prompt. */
2850
+ readonly PROMPT_HINT: "Disk hygiene: never let a command or script write an unbounded file. When you redirect or concatenate output (`> file`, `>> file`, `cat a b > all.md`), cap it (`| head -c 50M`, `| head -n 100000`) and never build a file from its own output. A file in your scratch dir or worktree that grows past a few GB is stopped by Crewly and the owner is alerted.";
2824
2851
  /** State file under CREWLY_HOME remembering when the owner was last told about low disk. */
2825
2852
  readonly STATE_FILENAME: "disk-janitor-state.json";
2826
2853
  /**